login-seat-flow-smoke-test.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. const assert = require('assert');
  2. const fs = require('fs');
  3. const http = require('http');
  4. const os = require('os');
  5. const path = require('path');
  6. const vm = require('vm');
  7. const {
  8. startLoginFlowServer,
  9. stopLoginFlowServer,
  10. subscribeErrorStatus,
  11. subscribeErrorBody
  12. } = require('../mcp/src/core/login-flow-server');
  13. const { classifySubscribeError } = require('../mcp/src/core/subscribe-page');
  14. async function body(req) {
  15. const chunks = [];
  16. for await (const chunk of req) chunks.push(chunk);
  17. if (!chunks.length) return {};
  18. return JSON.parse(Buffer.concat(chunks).toString('utf8'));
  19. }
  20. async function startGateway() {
  21. const requests = [];
  22. let failSubscribe = false;
  23. const server = http.createServer(async (req, res) => {
  24. const url = new URL(req.url, 'http://localhost');
  25. const input = await body(req);
  26. requests.push({ path: url.pathname, method: req.method, input, authorization: req.headers.authorization });
  27. let data;
  28. if (url.pathname === '/subscribe/status') {
  29. const quotedSeats = Number(url.searchParams.get('seats'));
  30. data = quotedSeats
  31. ? { subscribed: true, seats: 1, usedSeats: 1, quote: { seats: quotedSeats, amount: 125 } }
  32. : { subscribed: true, seats: 1, usedSeats: 1, price: 500 };
  33. } else if (url.pathname === '/login/accounts') {
  34. data = { seats: 1, usedSeats: 1, accounts: [{ id: `account_${'a'.repeat(40)}`, nickname: '原账号', corpName: '测试企业', online: false }] };
  35. } else if (url.pathname === '/login/status') {
  36. data = { configured: true, online: false, statusCode: -1 };
  37. } else if (url.pathname === '/login/select') {
  38. data = { selected: true, uid: input.uid, accountId: input.accountId, online: false };
  39. } else if (url.pathname === '/login/start') {
  40. data = {
  41. uid: input.newAccount ? 'qiwei-temporary-login' : input.uid,
  42. guid: input.newAccount ? 'GUID-NEW-DEVICE' : 'GUID-EXISTING-DEVICE',
  43. loginQrcodeBase64Data: Buffer.from('mock-png').toString('base64')
  44. };
  45. } else if (url.pathname === '/login/check') {
  46. data = {
  47. uid: input.uid,
  48. status: 2,
  49. detail: { corpId: 'corp-new', userId: 'user-new', nickname: '新账号' },
  50. decisionRequired: true,
  51. seats: 1,
  52. usedSeats: 1,
  53. account: { id: `account_${'b'.repeat(40)}`, nickname: '新账号' },
  54. accounts: [{ id: `account_${'a'.repeat(40)}`, nickname: '原账号' }],
  55. actions: ['purchase', 'replace', 'cancel']
  56. };
  57. } else if (url.pathname === '/login/resolve') {
  58. data = { resolved: true, action: input.action, cancelled: input.action === 'cancel' };
  59. } else if (url.pathname === '/subscribe') {
  60. if (failSubscribe) {
  61. res.writeHead(200, { 'Content-Type': 'application/json' });
  62. res.end(JSON.stringify({ code: 402, mess: '余额不足:当前 0 元,本次订阅需 500 元' }));
  63. return;
  64. }
  65. data = { seats: input.seats, amount: input.expectedAmount, state: 'active' };
  66. } else {
  67. res.writeHead(404, { 'Content-Type': 'application/json' });
  68. res.end(JSON.stringify({ code: 404, mess: 'not found' }));
  69. return;
  70. }
  71. res.writeHead(200, { 'Content-Type': 'application/json' });
  72. res.end(JSON.stringify({ code: 200, data }));
  73. });
  74. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  75. return {
  76. apiBase: `http://127.0.0.1:${server.address().port}`,
  77. requests,
  78. setSubscribeFailure(value) { failSubscribe = Boolean(value); },
  79. close: () => new Promise(resolve => server.close(resolve))
  80. };
  81. }
  82. async function main() {
  83. assert.equal(
  84. classifySubscribeError(409, '上游账号当前没有可用企微登录数量,请稍后重试'),
  85. 'QW-UP-409',
  86. 'upstream login capacity errors must not be shown as generic 502'
  87. );
  88. assert.strictEqual(
  89. subscribeErrorStatus({ httpStatus: 200, bizCode: 402 }),
  90. 402,
  91. 'business billing code must survive an HTTP 200 gateway response'
  92. );
  93. assert.strictEqual(
  94. subscribeErrorBody(402, '余额不足').errorCode,
  95. 'QW-PAY-402',
  96. 'billing errors must use the payment error code'
  97. );
  98. const gateway = await startGateway();
  99. const port = 4397;
  100. const envRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-guid-persist-'));
  101. fs.writeFileSync(path.join(envRoot, '.env.local'), 'QIWEI_UID=qiwei-client-one\nQIWEI_GUID=\n', 'utf8');
  102. const previousRoots = {
  103. package: process.env.QIWEI_PACKAGE_ROOT,
  104. workspace: process.env.QIWEI_WORKSPACE_ROOT,
  105. };
  106. process.env.QIWEI_PACKAGE_ROOT = envRoot;
  107. process.env.QIWEI_WORKSPACE_ROOT = envRoot;
  108. try {
  109. const { url } = await startLoginFlowServer({
  110. token: 'sk-login-seat-smoke',
  111. apiBase: gateway.apiBase,
  112. uid: 'qiwei-client-one',
  113. port
  114. });
  115. const page = await fetch(url).then(res => res.text());
  116. const inlineScript = page.match(/<script>([\s\S]*?)<\/script>/);
  117. assert(inlineScript, 'login flow page must include its client script');
  118. assert.doesNotThrow(() => new vm.Script(inlineScript[1]), 'login flow client script must be valid JavaScript');
  119. const checkoutToken = JSON.parse(page.match(/const CHECKOUT_TOKEN = ("[^"]+")/)[1]);
  120. const origin = `http://127.0.0.1:${port}`;
  121. gateway.setSubscribeFailure(true);
  122. const failedSubscribe = await fetch(`${origin}/flow/subscribe`, {
  123. method: 'POST',
  124. headers: { 'Content-Type': 'application/json', Origin: origin },
  125. body: JSON.stringify({ seats: 1, months: 1, idempotencyKey: 'subscribe-error-smoke-001', checkoutToken })
  126. });
  127. const failedBody = await failedSubscribe.json();
  128. assert.strictEqual(failedSubscribe.status, 402);
  129. assert.strictEqual(failedBody.errorCode, 'QW-PAY-402');
  130. gateway.setSubscribeFailure(false);
  131. const state = await fetch(`${origin}/flow/state`).then(res => res.json());
  132. assert.strictEqual(state.subscribed, true);
  133. assert.strictEqual(state.accounts.length, 1);
  134. const select = await fetch(`${origin}/flow/select-account`, {
  135. method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
  136. body: JSON.stringify({ accountId: state.accounts[0].id })
  137. }).then(res => res.json());
  138. assert.strictEqual(select.selected, true);
  139. const started = await fetch(`${origin}/flow/start-login`, {
  140. method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
  141. body: JSON.stringify({ newAccount: true })
  142. });
  143. assert.strictEqual(started.status, 200);
  144. assert.match(fs.readFileSync(path.join(envRoot, '.env.local'), 'utf8'), /^QIWEI_GUID=GUID-NEW-DEVICE$/m, 'new device GUID must be written immediately after QR creation');
  145. const decision = await fetch(`${origin}/flow/check`).then(res => res.json());
  146. assert.strictEqual(decision.decisionRequired, true);
  147. assert.deepStrictEqual(decision.actions, ['purchase', 'replace', 'cancel']);
  148. const quote = await fetch(`${origin}/flow/upgrade-quote`).then(res => res.json());
  149. assert.strictEqual(quote.targetSeats, 2);
  150. assert.strictEqual(quote.quote.amount, 125);
  151. const cancel = await fetch(`${origin}/flow/resolve`, {
  152. method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
  153. body: JSON.stringify({ checkoutToken, action: 'cancel' })
  154. }).then(res => res.json());
  155. assert.strictEqual(cancel.cancelled, true);
  156. const refreshedPage = await fetch(origin).then(res => res.text());
  157. const upgradeToken = JSON.parse(refreshedPage.match(/const CHECKOUT_TOKEN = ("[^"]+")/)[1]);
  158. await fetch(`${origin}/flow/start-login`, {
  159. method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
  160. body: JSON.stringify({ newAccount: true })
  161. });
  162. await fetch(`${origin}/flow/check`);
  163. const upgraded = await fetch(`${origin}/flow/upgrade`, {
  164. method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
  165. body: JSON.stringify({ checkoutToken: upgradeToken, idempotencyKey: 'seat-upgrade-smoke-001' })
  166. }).then(res => res.json());
  167. assert.strictEqual(upgraded.resolved, true);
  168. assert(gateway.requests.some(request => request.path === '/subscribe' && request.input.expectedAmount === 125));
  169. assert(gateway.requests.some(request => request.path === '/login/resolve' && request.input.action === 'purchase'));
  170. const replacePage = await fetch(origin).then(res => res.text());
  171. const replaceToken = JSON.parse(replacePage.match(/const CHECKOUT_TOKEN = ("[^"]+")/)[1]);
  172. await fetch(`${origin}/flow/start-login`, {
  173. method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
  174. body: JSON.stringify({ newAccount: true })
  175. });
  176. await fetch(`${origin}/flow/check`);
  177. const replaced = await fetch(`${origin}/flow/resolve`, {
  178. method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
  179. body: JSON.stringify({ checkoutToken: replaceToken, action: 'replace', replaceAccountId: state.accounts[0].id })
  180. }).then(res => res.json());
  181. assert.strictEqual(replaced.resolved, true);
  182. assert(gateway.requests.some(request => request.path === '/login/resolve' && request.input.action === 'replace'));
  183. assert(gateway.requests.every(request => request.authorization === 'Bearer sk-login-seat-smoke'));
  184. console.log('[ok] account selection and purchase, replace, cancel seat decisions');
  185. } finally {
  186. stopLoginFlowServer();
  187. await gateway.close();
  188. if (previousRoots.package === undefined) delete process.env.QIWEI_PACKAGE_ROOT;
  189. else process.env.QIWEI_PACKAGE_ROOT = previousRoots.package;
  190. if (previousRoots.workspace === undefined) delete process.env.QIWEI_WORKSPACE_ROOT;
  191. else process.env.QIWEI_WORKSPACE_ROOT = previousRoots.workspace;
  192. fs.rmSync(envRoot, { recursive: true, force: true });
  193. }
  194. }
  195. main().catch(error => {
  196. console.error(error);
  197. process.exitCode = 1;
  198. });