212 lines
17 KiB
JavaScript
212 lines
17 KiB
JavaScript
// 执行真实UTS服务及页面逻辑;仅微信授权、HTTP和设备存储采用边界替身。
|
|||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const path = require('node:path');
|
||
|
|
const vm = require('node:vm');
|
||
|
|
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||
|
|
const root = path.resolve(__dirname, '..');
|
||
|
|
class JsonObject {
|
||
|
|
constructor(value = {}) { Object.assign(this, value); }
|
||
|
|
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
|
||
|
|
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
|
||
|
|
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
|
||
|
|
}
|
||
|
|
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([k, v]) => [k, json(v)]))) : value;
|
||
|
|
const hash = 'a'.repeat(64);
|
||
|
|
const ticket = 'b'.repeat(64);
|
||
|
|
const terms = { revisionId: '1', code: 'terms', version: '2026.10-v1', scope: 'account-terms', title: '合成用户协议', content: '合成全文', contentHash: hash, effectiveAt: '2026-10-21T00:00:00Z' };
|
||
|
|
const privacy = { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
|
||
|
|
const agreements = [terms, privacy].map((item, i) => json({ agreementCode: item.code, agreementVersion: item.version, contentHash: hash, requestId: 'synthetic_consent_' + i }));
|
||
|
|
const authenticated = () => ({ status: 'authenticated', token: 'synthetic-wechat-token', userId: '7', phone: '13800000000', expiresAt: '2099-01-01T00:00:00Z', profile: { nickname: '合成用户', avatarUrl: '' }, consentsConfirmed: true });
|
||
|
|
const pending = () => ({ status: 'bind_required', bindingTicket: ticket, expiresIn: 600 });
|
||
|
|
function preprocess(source, platform) {
|
||
|
|
const stack = [true];
|
||
|
|
return source.split(/\r?\n/).filter(line => {
|
||
|
|
let match = line.match(/^\s*\/\/\s*#(ifdef|ifndef)\s+(.+)$/);
|
||
|
|
if (match) { const found = match[2].split(/\s*\|\|\s*/).includes(platform); stack.push(stack.at(-1) && (match[1] === 'ifdef' ? found : !found)); return false; }
|
||
|
|
if (/^\s*\/\/\s*#endif/.test(line)) { stack.pop(); return false; }
|
||
|
|
return stack.at(-1);
|
||
|
|
}).join('\n');
|
||
|
|
}
|
||
|
|
function environment(platform = 'MP-WEIXIN') {
|
||
|
|
let now = Date.now();
|
||
|
|
class Clock extends Date { static now() { return now; } }
|
||
|
|
const state = { session: null, calls: [], saves: [], redirects: [], authorizationCalls: 0, show: null, hide: null, unload: null };
|
||
|
|
state.reply = url => {
|
||
|
|
if (url === '/agreements/terms') return terms;
|
||
|
|
if (url === '/agreements/privacy') return privacy;
|
||
|
|
if (url === '/auth/wechat-login') return pending();
|
||
|
|
if (url === '/auth/wechat/sms-code' || url === '/auth/sms-code') return { retryAfter: 60, expiresIn: 300 };
|
||
|
|
if (url === '/auth/wechat/bind-phone') return authenticated();
|
||
|
|
throw Error('Unexpected request ' + url);
|
||
|
|
};
|
||
|
|
state.authorize = options => options.success({ code: 'synthetic-wechat-code' });
|
||
|
|
class ApiError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
|
||
|
|
state.ApiError = ApiError;
|
||
|
|
const api = { API_BASE_URL: 'https://synthetic.invalid/api/v1', ApiRequestError: ApiError,
|
||
|
|
getSession: () => state.session,
|
||
|
|
clearSession: () => { state.session = null; },
|
||
|
|
saveSession: (token, phone, userId, expiresAt) => { state.session = json({ token, phone, userId, expiresAt }); state.saves.push(state.session); },
|
||
|
|
requestJson: async (url, method, payload) => { state.calls.push({ url, method, payload: JSON.parse(JSON.stringify(payload)) }); return json(await state.reply(url, method, payload)); }
|
||
|
|
};
|
||
|
|
const watches = new Map();
|
||
|
|
const uni = { login: options => { state.authorizationCalls++; state.authorize(options); }, showToast() {}, showModal() {}, navigateTo() {}, redirectTo: ({ url }) => state.redirects.push(url), setStorageSync() {}, getStorageSync() { return null; } };
|
||
|
|
const modules = {};
|
||
|
|
function load(relative) {
|
||
|
|
if (modules[relative]) return modules[relative];
|
||
|
|
const full = path.join(root, relative);
|
||
|
|
assert.ok(fs.existsSync(full), '微信绑定服务尚未实现:' + relative);
|
||
|
|
const exports = {}; modules[relative] = exports;
|
||
|
|
run(preprocess(fs.readFileSync(full, 'utf8'), platform), relative, exports);
|
||
|
|
return exports;
|
||
|
|
}
|
||
|
|
function dependencies(name, relative) {
|
||
|
|
if (name === '@/services/api.uts' || name === './api.uts') return api;
|
||
|
|
if (name === '@/services/storage.uts') return { setUserStorage() {} };
|
||
|
|
if (name === '@/services/layout.uts') return { usePageLayout: () => ({ pageStyle() {}, contentStyle() {}, statusStyle() {} }) };
|
||
|
|
if (name === '@/services/launch.uts') return { consumeInitialLaunch() {} };
|
||
|
|
if (name.endsWith('.uvue')) return {};
|
||
|
|
return load(name.startsWith('@/') ? name.slice(2) : path.posix.normalize(path.posix.join(path.posix.dirname(relative), name)));
|
||
|
|
}
|
||
|
|
function run(source, relative, exports) {
|
||
|
|
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||
|
|
vm.runInNewContext(code, { exports, require: name => dependencies(name, relative), UTSJSONObject: JsonObject, Date: Clock, Error, Math, Number, uni,
|
||
|
|
ref: value => { const item = {}; Object.defineProperty(item, 'value', { get: () => value, set: next => { value = next; watches.get(item)?.(); } }); return item; },
|
||
|
|
computed: fn => ({ get value() { return fn(); } }), watch: (item, fn) => watches.set(item, fn),
|
||
|
|
onLoad() {}, onShow: fn => { state.show = fn; }, onHide: fn => { state.hide = fn; }, onUnload: fn => { state.unload = fn; }, onUnmounted() {}, setInterval: () => 1, clearInterval() {}
|
||
|
|
}, { filename: relative });
|
||
|
|
}
|
||
|
|
state.load = load;
|
||
|
|
state.advance = ms => { now += ms; };
|
||
|
|
state.page = () => {
|
||
|
|
const relative = 'packages/account/login/login.uvue';
|
||
|
|
const source = fs.readFileSync(path.join(root, relative), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||
|
|
const exports = {};
|
||
|
|
const names = ['login', 'beginWechatLogin', 'cancelWechatBinding', 'resendSms', 'phone', 'password', 'mode', 'smsCode', 'smsSent', 'countdown', 'loading', 'agreed', 'authenticated', 'errorMessage', 'wechatBindingTicket', 'toggleAgreement'];
|
||
|
|
run(preprocess(source, platform) + '\nexports.page={' + names.map(name => name + ':typeof ' + name + '==="undefined"?null:' + name).join(',') + '};', relative, exports);
|
||
|
|
return exports.page;
|
||
|
|
};
|
||
|
|
return state;
|
||
|
|
}
|
||
|
|
let checks = 0;
|
||
|
|
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
|
||
|
|
async function ready(state) { const page = state.page(); await state.show(); page.toggleAgreement(); return page; }
|
||
|
|
async function bindPage(state) { const page = await ready(state); await page.beginWechatLogin(); return page; }
|
||
|
|
(async () => {
|
||
|
|
await test('authorization and pending binding create no local login session', async () => {
|
||
|
|
const e = environment(); const service = e.load('packages/account/services/wechat-login.uts');
|
||
|
|
const result = await service.requestWechatLogin('synthetic-code', agreements);
|
||
|
|
assert.equal(result.status, 'bind_required'); assert.equal(e.saves.length, 0);
|
||
|
|
assert.deepEqual(e.calls[0], { url: '/auth/wechat-login', method: 'POST', payload: { platform: 'mp-weixin', code: 'synthetic-code', agreements: JSON.parse(JSON.stringify(agreements)) } });
|
||
|
|
});
|
||
|
|
await test('binding sends its own ticket and SMS endpoint instead of logging in a separate account', async () => {
|
||
|
|
const e = environment(); const service = e.load('packages/account/services/wechat-login.uts');
|
||
|
|
await service.requestWechatBindingCode(ticket, '13800000000');
|
||
|
|
const result = await service.bindWechatPhone(ticket, '13800000000', '123456');
|
||
|
|
assert.deepEqual(e.calls.map(call => call.url), ['/auth/wechat/sms-code', '/auth/wechat/bind-phone']);
|
||
|
|
assert.deepEqual(e.calls[1].payload, { bindingTicket: ticket, phone: '13800000000', code: '123456' });
|
||
|
|
assert.equal(e.saves.length, 0); service.acceptWechatSession(result);
|
||
|
|
assert.equal(e.session.userId, '7'); assert.equal(e.session.phone, '13800000000');
|
||
|
|
});
|
||
|
|
await test('malformed, pending, or unconfirmed results never save a session', async () => {
|
||
|
|
const e = environment(); const s = e.load('packages/account/services/wechat-login.uts');
|
||
|
|
for (const value of [pending(), { ...authenticated(), consentsConfirmed: false }, { ...authenticated(), phone: '' }, { ...authenticated(), userId: '' }, { ...authenticated(), expiresAt: '' }, { ...authenticated(), token: '' }]) assert.throws(() => s.acceptWechatSession(json(value)));
|
||
|
|
assert.equal(e.saves.length, 0);
|
||
|
|
e.reply = () => ({ ...pending(), token: 'must-not-be-issued' });
|
||
|
|
await assert.rejects(s.requestWechatLogin('synthetic-code', agreements));
|
||
|
|
});
|
||
|
|
await test('invalid phone, ticket or code is rejected before sending', async () => {
|
||
|
|
const e = environment(); const s = e.load('packages/account/services/wechat-login.uts');
|
||
|
|
await assert.rejects(s.requestWechatBindingCode('short', '13800000000'));
|
||
|
|
await assert.rejects(s.requestWechatBindingCode(ticket, '1380000'));
|
||
|
|
await assert.rejects(s.bindWechatPhone(ticket, '13800000000', '123'));
|
||
|
|
assert.equal(e.calls.length, 0);
|
||
|
|
});
|
||
|
|
await test('web and native app do not invoke the unconfigured WeChat adapter', async () => {
|
||
|
|
for (const platform of ['WEB', 'APP-ANDROID', 'APP-IOS']) {
|
||
|
|
const e = environment(platform); const s = e.load('packages/account/services/wechat-login.uts');
|
||
|
|
assert.equal(s.wechatLoginSupported(), false);
|
||
|
|
await assert.rejects(s.requestWechatAuthorization()); assert.equal(e.authorizationCalls, 0);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
await test('missing agreements block WeChat authorization even in password mode', async () => {
|
||
|
|
const e = environment(); e.reply = () => { throw new e.ApiError('协议未发布', 404, 'AGREEMENT_NOT_AVAILABLE'); };
|
||
|
|
const page = e.page(); await e.show(); await page.beginWechatLogin();
|
||
|
|
assert.equal(e.authorizationCalls, 0); assert.equal(e.saves.length, 0); assert.ok(page.errorMessage.value.length > 0);
|
||
|
|
});
|
||
|
|
await test('first WeChat authorization shows binding without creating a session', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e);
|
||
|
|
assert.equal(page.wechatBindingTicket.value, ticket); assert.equal(page.mode.value, 'phone');
|
||
|
|
assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
|
||
|
|
const commands = e.calls.find(call => call.url === '/auth/wechat-login').payload.agreements;
|
||
|
|
assert.deepEqual(commands.map(item => item.agreementCode), ['terms', 'privacy']);
|
||
|
|
assert.notEqual(commands[0].requestId, commands[1].requestId);
|
||
|
|
});
|
||
|
|
await test('verified binding saves original account exactly once and returns to the intended page', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000';
|
||
|
|
await page.login(); assert.equal(page.smsSent.value, true); assert.equal(e.saves.length, 0);
|
||
|
|
page.smsCode.value = '123456'; await page.login();
|
||
|
|
assert.equal(e.session.userId, '7'); assert.equal(e.saves.length, 1); assert.equal(e.redirects.length, 1);
|
||
|
|
assert.equal(e.calls.filter(call => call.url === '/consents').length, 0, 'server-confirmed agreements are not submitted twice');
|
||
|
|
});
|
||
|
|
await test('already bound WeChat can log in without requesting SMS', async () => {
|
||
|
|
const e = environment(); const reply = e.reply; e.reply = url => url === '/auth/wechat-login' ? authenticated() : reply(url);
|
||
|
|
const page = await ready(e); await page.beginWechatLogin();
|
||
|
|
assert.equal(e.saves.length, 1); assert.equal(e.redirects.length, 1);
|
||
|
|
assert.equal(e.calls.filter(call => call.url.includes('sms')).length, 0);
|
||
|
|
});
|
||
|
|
await test('cancelling pending binding returns to SMS login and removes its ticket', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; page.cancelWechatBinding();
|
||
|
|
assert.equal(page.wechatBindingTicket.value, ''); assert.equal(page.smsCode.value, '');
|
||
|
|
await page.login(); assert.equal(e.calls.at(-1).url, '/auth/sms-code'); assert.equal(e.saves.length, 0);
|
||
|
|
});
|
||
|
|
await test('expired ticket cannot send SMS or complete binding', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; e.advance(600001);
|
||
|
|
const before = e.calls.length; await page.login();
|
||
|
|
assert.equal(e.calls.length, before); assert.equal(page.wechatBindingTicket.value, ''); assert.match(page.errorMessage.value, /过期/);
|
||
|
|
});
|
||
|
|
await test('WeChat cancellation exposes an error and creates no account session', async () => {
|
||
|
|
const e = environment(); e.authorize = options => options.fail({ errCode: 1310509, errMsg: 'cancel' });
|
||
|
|
const page = await ready(e); await page.beginWechatLogin();
|
||
|
|
assert.equal(e.calls.filter(call => call.url === '/auth/wechat-login').length, 0); assert.equal(e.saves.length, 0); assert.match(page.errorMessage.value, /取消/);
|
||
|
|
});
|
||
|
|
await test('a WeChat response arriving after page hide cannot save or redirect', async () => {
|
||
|
|
const e = environment(); let release; const reply = e.reply;
|
||
|
|
e.reply = url => url === '/auth/wechat-login' ? new Promise(resolve => { release = resolve; }) : reply(url);
|
||
|
|
const page = await ready(e); const work = page.beginWechatLogin(); for (let i = 0; i < 12; i++) await Promise.resolve();
|
||
|
|
assert.equal(typeof release, 'function'); e.hide(); release(authenticated()); await work;
|
||
|
|
assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0); assert.equal(page.loading.value, false);
|
||
|
|
});
|
||
|
|
await test('a binding response cannot replace another account that logged in meanwhile', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||
|
|
let release; e.reply = () => new Promise(resolve => { release = resolve; });
|
||
|
|
const work = page.login(); for (let i = 0; i < 8; i++) await Promise.resolve();
|
||
|
|
e.session = json({ token: 'new-account-token', userId: '99' }); release(authenticated()); await work;
|
||
|
|
assert.equal(e.session.userId, '99'); assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
|
||
|
|
});
|
||
|
|
await test('double clicking sends only one binding command while the first is pending', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||
|
|
let release; e.reply = () => new Promise(resolve => { release = resolve; });
|
||
|
|
const first = page.login(); await page.login(); release(authenticated()); await first;
|
||
|
|
assert.equal(e.calls.filter(call => call.url === '/auth/wechat/bind-phone').length, 1); assert.equal(e.saves.length, 1);
|
||
|
|
});
|
||
|
|
await test('binding conflict keeps the account logged out and does not automatically retry', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||
|
|
e.reply = () => { throw new e.ApiError('绑定存在冲突,请使用原账户登录', 409, 'WECHAT_BINDING_CONFLICT'); };
|
||
|
|
await page.login(); assert.equal(e.saves.length, 0); assert.match(page.errorMessage.value, /冲突/);
|
||
|
|
assert.equal(e.calls.filter(call => call.url === '/auth/wechat/bind-phone').length, 1);
|
||
|
|
});
|
||
|
|
await test('an updated agreement cancels pending binding and requires a fresh checkbox', async () => {
|
||
|
|
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||
|
|
const reply = e.reply; e.reply = url => url === '/auth/wechat/bind-phone' ? Promise.reject(new e.ApiError('协议已更新', 409, 'AGREEMENT_CHANGED')) : reply(url);
|
||
|
|
await page.login(); assert.equal(page.wechatBindingTicket.value, ''); assert.equal(page.agreed.value, false); assert.equal(e.saves.length, 0);
|
||
|
|
});
|
||
|
|
for (const kind of ['sms', 'password']) await test(kind + ' login also ignores a response arriving after leaving the page', async () => {
|
||
|
|
const e = environment(); const page = await ready(e); page.phone.value = '13800000000'; page.password.value = 'SyntheticPass9'; page.mode.value = kind === 'sms' ? 'phone' : 'password';
|
||
|
|
if (kind === 'sms') { await page.login(); page.smsCode.value = '123456'; }
|
||
|
|
let release; e.reply = () => new Promise(resolve => { release = resolve; }); const work = page.login(); e.hide();
|
||
|
|
release(authenticated()); await work; assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
|
||
|
|
});
|
||
|
|
console.log(JSON.stringify({ checks, passed: true }));
|
||
|
|
})().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
|