|
@@ -0,0 +1,176 @@
|
|
|
|
|
+import test from 'node:test';
|
|
|
|
|
+import assert from 'node:assert/strict';
|
|
|
|
|
+import { readFile } from 'node:fs/promises';
|
|
|
|
|
+import { parse, compileScript } from '@vue/compiler-sfc';
|
|
|
|
|
+import ts from 'typescript';
|
|
|
|
|
+import * as vue from 'vue';
|
|
|
|
|
+
|
|
|
|
|
+const flowSource = await readFile(new URL('../src/utils/login-flow.ts', import.meta.url), 'utf8');
|
|
|
|
|
+const pageSource = await readFile(new URL('../src/pages/account/login.vue', import.meta.url), 'utf8');
|
|
|
|
|
+const { descriptor } = parse(pageSource, { filename: 'login.vue' });
|
|
|
|
|
+const script = compileScript(descriptor, { id: 'login-test' });
|
|
|
|
|
+
|
|
|
|
|
+function evaluate(source, dependencies, runtime = {}) {
|
|
|
|
|
+ const { outputText } = ts.transpileModule(source, {
|
|
|
|
|
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
|
|
|
|
|
+ });
|
|
|
|
|
+ const exports = {};
|
|
|
|
|
+ new Function('require', 'exports', 'setInterval', 'clearInterval', 'uni', outputText)(
|
|
|
|
|
+ (name) => {
|
|
|
|
|
+ if (name in dependencies) return dependencies[name];
|
|
|
|
|
+ throw new Error('Unexpected dependency: ' + name);
|
|
|
|
|
+ },
|
|
|
|
|
+ exports,
|
|
|
|
|
+ runtime.setInterval,
|
|
|
|
|
+ runtime.clearInterval,
|
|
|
|
|
+ runtime.uni,
|
|
|
|
|
+ );
|
|
|
|
|
+ return exports;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fixture(capabilities = { sms_login: true, test_phone_login: true }) {
|
|
|
|
|
+ const posts = [], messages = [], timers = new Map(), accepted = [], navigations = [];
|
|
|
|
|
+ let timerId = 0;
|
|
|
|
|
+ let unmount;
|
|
|
|
|
+ const client = {
|
|
|
|
|
+ post: (path, payload, authenticated) =>
|
|
|
|
|
+ new Promise((resolve, reject) => posts.push({ path, payload, authenticated, resolve, reject })),
|
|
|
|
|
+ };
|
|
|
|
|
+ const format = { toast: (message) => messages.push(message), go() {} };
|
|
|
|
|
+ const flow = evaluate(flowSource, { '../api/client': client, './format': format });
|
|
|
|
|
+ const state = evaluate(script.content, {
|
|
|
|
|
+ vue: { ...vue, onUnmounted: (callback) => { unmount = callback; } },
|
|
|
|
|
+ '@dcloudio/uni-app': { onShow() {} },
|
|
|
|
|
+ '../../stores/session': {
|
|
|
|
|
+ session: vue.reactive({ configuration: { capabilities } }),
|
|
|
|
|
+ loadConfiguration() {},
|
|
|
|
|
+ acceptLogin: (login) => accepted.push(login),
|
|
|
|
|
+ accountPage: () => '/pages/home/index',
|
|
|
|
|
+ },
|
|
|
|
|
+ '../../api/client': client,
|
|
|
|
|
+ '../../utils/format': format,
|
|
|
|
|
+ '../../utils/login-flow': flow,
|
|
|
|
|
+ }, {
|
|
|
|
|
+ setInterval: (callback) => { timers.set(++timerId, callback); return timerId; },
|
|
|
|
|
+ clearInterval: (id) => timers.delete(id),
|
|
|
|
|
+ uni: { reLaunch: (options) => navigations.push(options.url) },
|
|
|
|
|
+ }).default.setup({}, { expose() {} });
|
|
|
|
|
+ return { ...flow, state, posts, messages, timers, accepted, navigations, unmount };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+test('every phone, including former test numbers, requests a real SMS and uses SMS login', async () => {
|
|
|
|
|
+ for (const phone of ['13800138000', '13012345678', '13012345679', '13012345680']) {
|
|
|
|
|
+ const app = fixture();
|
|
|
|
|
+ const send = app.requestPhoneCode(phone);
|
|
|
|
|
+ assert.equal(app.posts.length, 1, 'must not fake sending for test numbers');
|
|
|
|
|
+ assert.equal(app.posts[0].path, '/auth/send-sms');
|
|
|
|
|
+ assert.deepEqual(app.posts[0].payload, { phone });
|
|
|
|
|
+ assert.equal(app.posts[0].authenticated, false);
|
|
|
|
|
+ app.posts[0].resolve({ sent: true, expires_in: 300 });
|
|
|
|
|
+ assert.equal(await send, true);
|
|
|
|
|
+ const login = app.loginByPhoneCode(phone, '728415');
|
|
|
|
|
+ assert.equal(app.posts[1].path, '/auth/phone-login');
|
|
|
|
|
+ assert.deepEqual(app.posts[1].payload, { phone, code: '728415' });
|
|
|
|
|
+ assert.equal(app.posts[1].authenticated, false);
|
|
|
|
|
+ app.posts[1].resolve({ user_id: '42' });
|
|
|
|
|
+ assert.equal((await login).user_id, '42');
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('get code works before agreement, serializes taps, and starts countdown only after success', async () => {
|
|
|
|
|
+ const app = fixture();
|
|
|
|
|
+ app.state.phone.value = '13800138000';
|
|
|
|
|
+ app.state.phoneCode.value = '123456';
|
|
|
|
|
+ assert.equal(app.state.agreed.value, false);
|
|
|
|
|
+ const send = app.state.sendCode();
|
|
|
|
|
+ await app.state.sendCode();
|
|
|
|
|
+ assert.equal(app.posts.length, 1);
|
|
|
|
|
+ assert.equal(app.state.sendingCode.value, true);
|
|
|
|
|
+ assert.equal(app.state.codeCountdown.value, 0);
|
|
|
|
|
+ app.posts[0].resolve({ sent: true, expires_in: 300 });
|
|
|
|
|
+ await send;
|
|
|
|
|
+ assert.equal(app.state.sendingCode.value, false);
|
|
|
|
|
+ assert.equal(app.state.phoneCode.value, '');
|
|
|
|
|
+ assert.equal(app.state.codeCountdown.value, 60);
|
|
|
|
|
+ assert.match(app.messages.at(-1), /查看手机短信/);
|
|
|
|
|
+ await app.state.sendCode();
|
|
|
|
|
+ assert.equal(app.posts.length, 1, 'countdown prevents resending');
|
|
|
|
|
+ for (let i = 0; i < 60; i++) [...app.timers.values()].forEach((tick) => tick());
|
|
|
|
|
+ assert.equal(app.state.codeCountdown.value, 0);
|
|
|
|
|
+ assert.equal(app.timers.size, 0);
|
|
|
|
|
+ const resend = app.state.sendCode();
|
|
|
|
|
+ assert.equal(app.posts.length, 2);
|
|
|
|
|
+ app.posts[1].resolve({ sent: true });
|
|
|
|
|
+ await resend;
|
|
|
|
|
+ app.unmount();
|
|
|
|
|
+ assert.equal(app.timers.size, 0);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('failed SMS shows the server error, releases the button and permits retry without countdown', async () => {
|
|
|
|
|
+ const app = fixture();
|
|
|
|
|
+ app.state.phone.value = '13800138000';
|
|
|
|
|
+ const send = app.state.sendCode();
|
|
|
|
|
+ app.posts[0].reject(new Error('短信发送失败,请稍后再试'));
|
|
|
|
|
+ await send;
|
|
|
|
|
+ assert.match(app.messages.at(-1), /短信发送失败/);
|
|
|
|
|
+ assert.equal(app.state.sendingCode.value, false);
|
|
|
|
|
+ assert.equal(app.state.codeCountdown.value, 0);
|
|
|
|
|
+ assert.equal(app.timers.size, 0);
|
|
|
|
|
+ const retry = app.state.sendCode();
|
|
|
|
|
+ assert.equal(app.posts.length, 2);
|
|
|
|
|
+ app.posts[1].resolve({ sent: true });
|
|
|
|
|
+ await retry;
|
|
|
|
|
+ app.unmount();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('test-login capability alone cannot enable random SMS login', async () => {
|
|
|
|
|
+ const app = fixture({ test_phone_login: true });
|
|
|
|
|
+ app.state.phone.value = '13012345678';
|
|
|
|
|
+ assert.equal(app.state.phoneLoginAvailable.value, false);
|
|
|
|
|
+ await app.state.sendCode();
|
|
|
|
|
+ assert.equal(app.posts.length, 0);
|
|
|
|
|
+ assert.match(app.messages.at(-1), /短信登录暂未开放/);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('invalid numbers are explained without sending requests', async () => {
|
|
|
|
|
+ const app = fixture();
|
|
|
|
|
+ app.state.phone.value = '12345';
|
|
|
|
|
+ await app.state.sendCode();
|
|
|
|
|
+ assert.equal(app.posts.length, 0);
|
|
|
|
|
+ assert.match(app.messages.at(-1), /正确的手机号码/);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('login still requires agreement, forwards the entered code and never uses test-phone', async () => {
|
|
|
|
|
+ const app = fixture();
|
|
|
|
|
+ app.state.phone.value = '13012345678';
|
|
|
|
|
+ app.state.phoneCode.value = '728415';
|
|
|
|
|
+ await app.state.loginByPhone();
|
|
|
|
|
+ assert.equal(app.posts.length, 0);
|
|
|
|
|
+ assert.match(app.messages.at(-1), /同意服务说明/);
|
|
|
|
|
+ app.state.agreed.value = true;
|
|
|
|
|
+ const login = app.state.loginByPhone();
|
|
|
|
|
+ await app.state.loginByPhone();
|
|
|
|
|
+ assert.equal(app.posts.length, 1);
|
|
|
|
|
+ assert.equal(app.posts[0].path, '/auth/phone-login');
|
|
|
|
|
+ assert.deepEqual(app.posts[0].payload, { phone: '13012345678', code: '728415' });
|
|
|
|
|
+ const session = { user_id: '42', access_token: 'token', identities: [] };
|
|
|
|
|
+ app.posts[0].resolve(session);
|
|
|
|
|
+ await login;
|
|
|
|
|
+ assert.deepEqual(app.accepted, [session]);
|
|
|
|
|
+ assert.deepEqual(app.navigations, ['/pages/home/index']);
|
|
|
|
|
+ assert.equal(app.state.busy.value, false);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('wrong code is reported without accepting a session or navigating away', async () => {
|
|
|
|
|
+ const app = fixture();
|
|
|
|
|
+ app.state.phone.value = '13800138000';
|
|
|
|
|
+ app.state.phoneCode.value = '123456';
|
|
|
|
|
+ app.state.agreed.value = true;
|
|
|
|
|
+ const login = app.state.loginByPhone();
|
|
|
|
|
+ app.posts[0].reject(new Error('验证码错误'));
|
|
|
|
|
+ await login;
|
|
|
|
|
+ assert.match(app.messages.at(-1), /验证码错误/);
|
|
|
|
|
+ assert.equal(app.accepted.length, 0);
|
|
|
|
|
+ assert.equal(app.navigations.length, 0);
|
|
|
|
|
+ assert.equal(app.state.busy.value, false);
|
|
|
|
|
+});
|