login-flow.test.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { readFile } from 'node:fs/promises';
  4. import { parse, compileScript } from '@vue/compiler-sfc';
  5. import ts from 'typescript';
  6. import * as vue from 'vue';
  7. const flowSource = await readFile(new URL('../src/utils/login-flow.ts', import.meta.url), 'utf8');
  8. const pageSource = await readFile(
  9. new URL('../src/pages/account/login.vue', import.meta.url),
  10. 'utf8',
  11. );
  12. const { descriptor } = parse(pageSource, { filename: 'login.vue' });
  13. const script = compileScript(descriptor, { id: 'login-test' });
  14. function evaluate(source, dependencies, runtime = {}) {
  15. const { outputText } = ts.transpileModule(source, {
  16. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
  17. });
  18. const exports = {};
  19. new Function('require', 'exports', 'setInterval', 'clearInterval', 'uni', outputText)(
  20. (name) => {
  21. if (name in dependencies) return dependencies[name];
  22. throw new Error('Unexpected dependency: ' + name);
  23. },
  24. exports,
  25. runtime.setInterval,
  26. runtime.clearInterval,
  27. runtime.uni,
  28. );
  29. return exports;
  30. }
  31. function fixture(capabilities = { sms_login: true, test_phone_login: true }) {
  32. const posts = [],
  33. messages = [],
  34. timers = new Map(),
  35. accepted = [],
  36. navigations = [];
  37. let timerId = 0;
  38. let unmount;
  39. const client = {
  40. post: (path, payload, authenticated) =>
  41. new Promise((resolve, reject) =>
  42. posts.push({ path, payload, authenticated, resolve, reject }),
  43. ),
  44. };
  45. const format = { toast: (message) => messages.push(message), go() {} };
  46. const flow = evaluate(flowSource, { '../api/client': client, './format': format });
  47. const state = evaluate(
  48. script.content,
  49. {
  50. vue: {
  51. ...vue,
  52. onUnmounted: (callback) => {
  53. unmount = callback;
  54. },
  55. },
  56. '@dcloudio/uni-app': { onShow() {} },
  57. '../../stores/session': {
  58. session: vue.reactive({ configuration: { capabilities } }),
  59. loadConfiguration() {},
  60. acceptLogin: (login) => accepted.push(login),
  61. accountPage: () => '/pages/home/index',
  62. },
  63. '../../api/client': client,
  64. '../../utils/format': format,
  65. '../../utils/login-flow': flow,
  66. },
  67. {
  68. setInterval: (callback) => {
  69. timers.set(++timerId, callback);
  70. return timerId;
  71. },
  72. clearInterval: (id) => timers.delete(id),
  73. uni: { reLaunch: (options) => navigations.push(options.url) },
  74. },
  75. ).default.setup({}, { expose() {} });
  76. return { ...flow, state, posts, messages, timers, accepted, navigations, unmount };
  77. }
  78. test('real phone numbers request SMS and use SMS login', async () => {
  79. for (const phone of ['13800138000', '13900139000']) {
  80. const app = fixture();
  81. const send = app.requestPhoneCode(phone);
  82. assert.equal(app.posts.length, 1);
  83. assert.equal(app.posts[0].path, '/auth/send-sms');
  84. assert.deepEqual(app.posts[0].payload, { phone });
  85. assert.equal(app.posts[0].authenticated, false);
  86. app.posts[0].resolve({ sent: true, expires_in: 120 });
  87. assert.equal(await send, true);
  88. const login = app.loginByPhoneCode(phone, '728415');
  89. assert.equal(app.posts[1].path, '/auth/phone-login');
  90. assert.deepEqual(app.posts[1].payload, { phone, code: '728415' });
  91. assert.equal(app.posts[1].authenticated, false);
  92. app.posts[1].resolve({ user_id: '42' });
  93. assert.equal((await login).user_id, '42');
  94. }
  95. });
  96. test('all three test accounts skip SMS and submit their five-digit code to test-phone', async () => {
  97. for (const phone of ['13012345678', '13012345679', '13012345680']) {
  98. const app = fixture();
  99. assert.equal(await app.requestPhoneCode(phone), false);
  100. assert.equal(app.posts.length, 0);
  101. assert.match(app.messages.at(-1), /无需获取短信/);
  102. app.state.phone.value = phone;
  103. app.state.phoneCode.value = '66666';
  104. app.state.agreed.value = true;
  105. const login = app.state.loginByPhone();
  106. assert.equal(app.posts.length, 1);
  107. assert.equal(app.posts[0].path, '/auth/test-phone');
  108. assert.deepEqual(app.posts[0].payload, { phone, code: '66666' });
  109. assert.equal(app.posts[0].authenticated, false);
  110. app.posts[0].resolve({ user_id: '42' });
  111. await login;
  112. assert.equal(app.accepted.length, 1);
  113. }
  114. });
  115. test('get code works before agreement, serializes taps, and starts countdown only after success', async () => {
  116. const app = fixture();
  117. app.state.phone.value = '13800138000';
  118. app.state.phoneCode.value = '123456';
  119. assert.equal(app.state.agreed.value, false);
  120. const send = app.state.sendCode();
  121. await app.state.sendCode();
  122. assert.equal(app.posts.length, 1);
  123. assert.equal(app.state.sendingCode.value, true);
  124. assert.equal(app.state.codeCountdown.value, 0);
  125. app.posts[0].resolve({ sent: true, expires_in: 120 });
  126. await send;
  127. assert.equal(app.state.sendingCode.value, false);
  128. assert.equal(app.state.phoneCode.value, '');
  129. assert.equal(app.state.codeCountdown.value, 60);
  130. assert.match(app.messages.at(-1), /查看手机短信/);
  131. await app.state.sendCode();
  132. assert.equal(app.posts.length, 1, 'countdown prevents resending');
  133. for (let i = 0; i < 60; i++) [...app.timers.values()].forEach((tick) => tick());
  134. assert.equal(app.state.codeCountdown.value, 0);
  135. assert.equal(app.timers.size, 0);
  136. const resend = app.state.sendCode();
  137. assert.equal(app.posts.length, 2);
  138. app.posts[1].resolve({ sent: true });
  139. await resend;
  140. app.unmount();
  141. assert.equal(app.timers.size, 0);
  142. });
  143. test('failed SMS shows the server error, releases the button and permits retry without countdown', async () => {
  144. const app = fixture();
  145. app.state.phone.value = '13800138000';
  146. const send = app.state.sendCode();
  147. app.posts[0].reject(new Error('短信发送失败,请稍后再试'));
  148. await send;
  149. assert.match(app.messages.at(-1), /短信发送失败/);
  150. assert.equal(app.state.sendingCode.value, false);
  151. assert.equal(app.state.codeCountdown.value, 0);
  152. assert.equal(app.timers.size, 0);
  153. const retry = app.state.sendCode();
  154. assert.equal(app.posts.length, 2);
  155. app.posts[1].resolve({ sent: true });
  156. await retry;
  157. app.unmount();
  158. });
  159. test('test capability enables test login without enabling real SMS', async () => {
  160. const app = fixture({ test_phone_login: true });
  161. app.state.phone.value = '13012345678';
  162. assert.equal(app.state.phoneLoginAvailable.value, true);
  163. await app.state.sendCode();
  164. assert.equal(app.posts.length, 0);
  165. assert.equal(app.state.codeCountdown.value, 0);
  166. assert.match(app.messages.at(-1), /无需获取短信/);
  167. app.state.phone.value = '13800138000';
  168. await app.state.sendCode();
  169. assert.equal(app.posts.length, 0);
  170. assert.match(app.messages.at(-1), /短信登录暂未开放/);
  171. });
  172. test('disabled test login cannot fall through to SMS even when SMS is enabled', async () => {
  173. const app = fixture({ sms_login: true, test_phone_login: false });
  174. app.state.phone.value = '13012345678';
  175. app.state.phoneCode.value = '66666';
  176. app.state.agreed.value = true;
  177. await app.state.loginByPhone();
  178. await app.state.sendCode();
  179. assert.equal(app.posts.length, 0);
  180. assert.match(app.messages.at(-1), /未开启测试账号登录/);
  181. });
  182. test('phone login is unavailable only when both capabilities are disabled', () => {
  183. assert.equal(
  184. fixture({ sms_login: false, test_phone_login: false }).state.phoneLoginAvailable.value,
  185. false,
  186. );
  187. });
  188. test('invalid numbers are explained without sending requests', async () => {
  189. const app = fixture();
  190. app.state.phone.value = '12345';
  191. await app.state.sendCode();
  192. assert.equal(app.posts.length, 0);
  193. assert.match(app.messages.at(-1), /正确的手机号码/);
  194. });
  195. test('real login requires agreement, forwards the code and serializes taps', async () => {
  196. const app = fixture();
  197. app.state.phone.value = '13800138000';
  198. app.state.phoneCode.value = '728415';
  199. await app.state.loginByPhone();
  200. assert.equal(app.posts.length, 0);
  201. assert.match(app.messages.at(-1), /同意服务说明/);
  202. app.state.agreed.value = true;
  203. const login = app.state.loginByPhone();
  204. await app.state.loginByPhone();
  205. assert.equal(app.posts.length, 1);
  206. assert.equal(app.posts[0].path, '/auth/phone-login');
  207. assert.deepEqual(app.posts[0].payload, { phone: '13800138000', code: '728415' });
  208. const session = { user_id: '42', access_token: 'token', identities: [] };
  209. app.posts[0].resolve(session);
  210. await login;
  211. assert.deepEqual(app.accepted, [session]);
  212. assert.deepEqual(app.navigations, ['/pages/home/index']);
  213. assert.equal(app.state.busy.value, false);
  214. });
  215. test('code length follows the account type and fixed code cannot log into a real number', async () => {
  216. const app = fixture();
  217. app.state.agreed.value = true;
  218. app.state.phone.value = '13800138000';
  219. app.state.phoneCode.value = '66666';
  220. await app.state.loginByPhone();
  221. app.state.phone.value = '13012345678';
  222. app.state.phoneCode.value = '123456';
  223. await app.state.loginByPhone();
  224. assert.equal(app.posts.length, 0);
  225. assert.match(app.messages.at(-1), /正确的手机号和验证码/);
  226. });
  227. test('wrong code is reported without accepting a session or navigating away', async () => {
  228. const app = fixture();
  229. app.state.phone.value = '13800138000';
  230. app.state.phoneCode.value = '123456';
  231. app.state.agreed.value = true;
  232. const login = app.state.loginByPhone();
  233. app.posts[0].reject(new Error('验证码错误'));
  234. await login;
  235. assert.match(app.messages.at(-1), /验证码错误/);
  236. assert.equal(app.accepted.length, 0);
  237. assert.equal(app.navigations.length, 0);
  238. assert.equal(app.state.busy.value, false);
  239. });