login-flow.test.mjs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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(new URL('../src/pages/account/login.vue', import.meta.url), 'utf8');
  9. const { descriptor } = parse(pageSource, { filename: 'login.vue' });
  10. const script = compileScript(descriptor, { id: 'login-test' });
  11. function evaluate(source, dependencies, runtime = {}) {
  12. const { outputText } = ts.transpileModule(source, {
  13. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
  14. });
  15. const exports = {};
  16. new Function('require', 'exports', 'setInterval', 'clearInterval', 'uni', outputText)(
  17. (name) => {
  18. if (name in dependencies) return dependencies[name];
  19. throw new Error('Unexpected dependency: ' + name);
  20. },
  21. exports,
  22. runtime.setInterval,
  23. runtime.clearInterval,
  24. runtime.uni,
  25. );
  26. return exports;
  27. }
  28. function fixture(capabilities = { sms_login: true, test_phone_login: true }) {
  29. const posts = [], messages = [], timers = new Map(), accepted = [], navigations = [];
  30. let timerId = 0;
  31. let unmount;
  32. const client = {
  33. post: (path, payload, authenticated) =>
  34. new Promise((resolve, reject) => posts.push({ path, payload, authenticated, resolve, reject })),
  35. };
  36. const format = { toast: (message) => messages.push(message), go() {} };
  37. const flow = evaluate(flowSource, { '../api/client': client, './format': format });
  38. const state = evaluate(script.content, {
  39. vue: { ...vue, onUnmounted: (callback) => { unmount = callback; } },
  40. '@dcloudio/uni-app': { onShow() {} },
  41. '../../stores/session': {
  42. session: vue.reactive({ configuration: { capabilities } }),
  43. loadConfiguration() {},
  44. acceptLogin: (login) => accepted.push(login),
  45. accountPage: () => '/pages/home/index',
  46. },
  47. '../../api/client': client,
  48. '../../utils/format': format,
  49. '../../utils/login-flow': flow,
  50. }, {
  51. setInterval: (callback) => { timers.set(++timerId, callback); return timerId; },
  52. clearInterval: (id) => timers.delete(id),
  53. uni: { reLaunch: (options) => navigations.push(options.url) },
  54. }).default.setup({}, { expose() {} });
  55. return { ...flow, state, posts, messages, timers, accepted, navigations, unmount };
  56. }
  57. test('every phone, including former test numbers, requests a real SMS and uses SMS login', async () => {
  58. for (const phone of ['13800138000', '13012345678', '13012345679', '13012345680']) {
  59. const app = fixture();
  60. const send = app.requestPhoneCode(phone);
  61. assert.equal(app.posts.length, 1, 'must not fake sending for test numbers');
  62. assert.equal(app.posts[0].path, '/auth/send-sms');
  63. assert.deepEqual(app.posts[0].payload, { phone });
  64. assert.equal(app.posts[0].authenticated, false);
  65. app.posts[0].resolve({ sent: true, expires_in: 300 });
  66. assert.equal(await send, true);
  67. const login = app.loginByPhoneCode(phone, '728415');
  68. assert.equal(app.posts[1].path, '/auth/phone-login');
  69. assert.deepEqual(app.posts[1].payload, { phone, code: '728415' });
  70. assert.equal(app.posts[1].authenticated, false);
  71. app.posts[1].resolve({ user_id: '42' });
  72. assert.equal((await login).user_id, '42');
  73. }
  74. });
  75. test('get code works before agreement, serializes taps, and starts countdown only after success', async () => {
  76. const app = fixture();
  77. app.state.phone.value = '13800138000';
  78. app.state.phoneCode.value = '123456';
  79. assert.equal(app.state.agreed.value, false);
  80. const send = app.state.sendCode();
  81. await app.state.sendCode();
  82. assert.equal(app.posts.length, 1);
  83. assert.equal(app.state.sendingCode.value, true);
  84. assert.equal(app.state.codeCountdown.value, 0);
  85. app.posts[0].resolve({ sent: true, expires_in: 300 });
  86. await send;
  87. assert.equal(app.state.sendingCode.value, false);
  88. assert.equal(app.state.phoneCode.value, '');
  89. assert.equal(app.state.codeCountdown.value, 60);
  90. assert.match(app.messages.at(-1), /查看手机短信/);
  91. await app.state.sendCode();
  92. assert.equal(app.posts.length, 1, 'countdown prevents resending');
  93. for (let i = 0; i < 60; i++) [...app.timers.values()].forEach((tick) => tick());
  94. assert.equal(app.state.codeCountdown.value, 0);
  95. assert.equal(app.timers.size, 0);
  96. const resend = app.state.sendCode();
  97. assert.equal(app.posts.length, 2);
  98. app.posts[1].resolve({ sent: true });
  99. await resend;
  100. app.unmount();
  101. assert.equal(app.timers.size, 0);
  102. });
  103. test('failed SMS shows the server error, releases the button and permits retry without countdown', async () => {
  104. const app = fixture();
  105. app.state.phone.value = '13800138000';
  106. const send = app.state.sendCode();
  107. app.posts[0].reject(new Error('短信发送失败,请稍后再试'));
  108. await send;
  109. assert.match(app.messages.at(-1), /短信发送失败/);
  110. assert.equal(app.state.sendingCode.value, false);
  111. assert.equal(app.state.codeCountdown.value, 0);
  112. assert.equal(app.timers.size, 0);
  113. const retry = app.state.sendCode();
  114. assert.equal(app.posts.length, 2);
  115. app.posts[1].resolve({ sent: true });
  116. await retry;
  117. app.unmount();
  118. });
  119. test('test-login capability alone cannot enable random SMS login', async () => {
  120. const app = fixture({ test_phone_login: true });
  121. app.state.phone.value = '13012345678';
  122. assert.equal(app.state.phoneLoginAvailable.value, false);
  123. await app.state.sendCode();
  124. assert.equal(app.posts.length, 0);
  125. assert.match(app.messages.at(-1), /短信登录暂未开放/);
  126. });
  127. test('invalid numbers are explained without sending requests', async () => {
  128. const app = fixture();
  129. app.state.phone.value = '12345';
  130. await app.state.sendCode();
  131. assert.equal(app.posts.length, 0);
  132. assert.match(app.messages.at(-1), /正确的手机号码/);
  133. });
  134. test('login still requires agreement, forwards the entered code and never uses test-phone', async () => {
  135. const app = fixture();
  136. app.state.phone.value = '13012345678';
  137. app.state.phoneCode.value = '728415';
  138. await app.state.loginByPhone();
  139. assert.equal(app.posts.length, 0);
  140. assert.match(app.messages.at(-1), /同意服务说明/);
  141. app.state.agreed.value = true;
  142. const login = app.state.loginByPhone();
  143. await app.state.loginByPhone();
  144. assert.equal(app.posts.length, 1);
  145. assert.equal(app.posts[0].path, '/auth/phone-login');
  146. assert.deepEqual(app.posts[0].payload, { phone: '13012345678', code: '728415' });
  147. const session = { user_id: '42', access_token: 'token', identities: [] };
  148. app.posts[0].resolve(session);
  149. await login;
  150. assert.deepEqual(app.accepted, [session]);
  151. assert.deepEqual(app.navigations, ['/pages/home/index']);
  152. assert.equal(app.state.busy.value, false);
  153. });
  154. test('wrong code is reported without accepting a session or navigating away', async () => {
  155. const app = fixture();
  156. app.state.phone.value = '13800138000';
  157. app.state.phoneCode.value = '123456';
  158. app.state.agreed.value = true;
  159. const login = app.state.loginByPhone();
  160. app.posts[0].reject(new Error('验证码错误'));
  161. await login;
  162. assert.match(app.messages.at(-1), /验证码错误/);
  163. assert.equal(app.accepted.length, 0);
  164. assert.equal(app.navigations.length, 0);
  165. assert.equal(app.state.busy.value, false);
  166. });