mobile-mp-webview-url.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { parse } from '@vue/compiler-sfc'
  5. import { parseQuery, stringifyQuery } from 'vue-router'
  6. import ts from 'typescript'
  7. import { stripMpTicketFromUrl } from '../src/utils/mp-webview'
  8. const file = new URL('../../miniprogram/pages/common/mp-webview.vue', import.meta.url)
  9. const { descriptor } = parse(readFileSync(file, 'utf8'))
  10. const ast = ts.createSourceFile(
  11. 'mp-webview.js',
  12. descriptor.scriptSetup!.content,
  13. ts.ScriptTarget.Latest,
  14. true
  15. )
  16. const declarations = ast.statements.filter(
  17. (node) =>
  18. ts.isFunctionDeclaration(node) &&
  19. ['bootstrapWebView', 'buildMpWebviewUrl', 'normalizePath', 'isAllowedPath'].includes(
  20. node.name?.text || ''
  21. )
  22. )
  23. // Run the page's actual URL composition with inert host/login and ticket fixtures.
  24. // This sandbox makes no browser, HTTP or database calls and reads no credentials.
  25. async function open(path: string, returnOrigin = '') {
  26. const bindings = {
  27. useUserStore: () => ({ checkLoginStatus() {}, isLoggedIn: true }),
  28. assertTicketEnvAligned() {},
  29. fetchMpWebviewTicket: async () => 'fixture+ticket/=本次',
  30. resolveWebOrigin: () => 'https://h5.example.test/',
  31. window: returnOrigin ? { location: { origin: returnOrigin } } : undefined,
  32. errorText: { value: '' },
  33. uni: { showToast: ({ title }: { title: string }) => assert.fail(title) }
  34. }
  35. const code = declarations.map((node) => node.getText(ast)).join('\n')
  36. const run = new Function(
  37. ...Object.keys(bindings),
  38. `
  39. let pendingSrc = ''
  40. function tryMountWebView() {}
  41. ${code}
  42. return async (path) => {
  43. await bootstrapWebView(path)
  44. return pendingSrc
  45. }
  46. `
  47. )(...Object.values(bindings))
  48. const url = new URL(await run(path))
  49. const hash = url.hash.slice(1)
  50. const question = hash.indexOf('?')
  51. return {
  52. url,
  53. path: hash.slice(0, question),
  54. query: new URLSearchParams(hash.slice(question + 1))
  55. }
  56. }
  57. function assertBridge(
  58. result: Awaited<ReturnType<typeof open>>,
  59. expectedPath: string,
  60. returnOrigin = ''
  61. ) {
  62. assert.equal(result.url.origin, 'https://h5.example.test')
  63. assert.equal(result.url.pathname, '/')
  64. assert.equal(result.path, expectedPath)
  65. assert.equal(result.url.hash.match(/\?/g)?.length, 1)
  66. assert.equal(result.url.searchParams.get('mpTicket'), 'fixture+ticket/=本次')
  67. assert.deepEqual(result.query.getAll('mpTicket'), ['fixture+ticket/=本次'])
  68. assert.equal(result.url.searchParams.get('mpReturnOrigin'), returnOrigin || null)
  69. assert.equal(result.query.get('mpReturnOrigin'), returnOrigin || null)
  70. const cleaned = new URLSearchParams(
  71. stringifyQuery(
  72. stripMpTicketFromUrl(result.path, parseQuery(result.query.toString())) as Parameters<
  73. typeof stringifyQuery
  74. >[0]
  75. )
  76. )
  77. assert.equal(cleaned.has('mpTicket'), false)
  78. return cleaned
  79. }
  80. test('a route without business query keeps the root and hash ticket bootstrap contract', async () => {
  81. const result = await open('/m/crm/customer')
  82. assertBridge(result, '/m/crm/customer')
  83. })
  84. test('copyFrom stays a business parameter and cannot absorb a second question mark and ticket', async () => {
  85. const result = await open('/m/crm/quote/form?copyFrom=quote-123')
  86. const cleaned = assertBridge(result, '/m/crm/quote/form')
  87. assert.deepEqual([...cleaned], [['copyFrom', 'quote-123']])
  88. })
  89. test('tabs, multiple values and platform metadata survive ticket insertion and cleanup', async () => {
  90. const result = await open(
  91. '/m/crm/customer/detail?tab=follow&id=c1&tag=a&tag=b&platform=weixin&mpPlatform=wechat'
  92. )
  93. const cleaned = assertBridge(result, '/m/crm/customer/detail')
  94. assert.deepEqual(
  95. [...cleaned],
  96. [
  97. ['tab', 'follow'],
  98. ['id', 'c1'],
  99. ['tag', 'a'],
  100. ['tag', 'b'],
  101. ['platform', 'weixin'],
  102. ['mpPlatform', 'wechat']
  103. ]
  104. )
  105. })
  106. test('encoded business values keep their original encoding and decoded values', async () => {
  107. const query =
  108. 'copyFrom=%E5%AE%A2%E6%88%B7%3F%26%3D%2B%23%25&label=two+words&literal=%252F&flag&empty='
  109. const result = await open(`/m/crm/quote/form?${query}`)
  110. const cleaned = assertBridge(result, '/m/crm/quote/form')
  111. assert.equal(cleaned.get('copyFrom'), '客户?&=+#%')
  112. assert.equal(cleaned.get('label'), 'two words')
  113. assert.equal(cleaned.get('literal'), '%2F')
  114. assert.equal(cleaned.get('flag'), '')
  115. assert.equal(cleaned.get('empty'), '')
  116. assert.ok(result.url.hash.includes(`?${query}&mpTicket=`))
  117. })
  118. test('new ticket and return origin replace all old bridge values including encoded keys', async () => {
  119. const returnOrigin = 'https://mini.example.test:8080'
  120. const result = await open(
  121. '/m/crm/quote/form?mpTicket=old&copyFrom=q1&%6DpTicket=older&mpReturnOrigin=https%3A%2F%2Fold.example.test&platform=h5',
  122. returnOrigin
  123. )
  124. const cleaned = assertBridge(result, '/m/crm/quote/form', returnOrigin)
  125. assert.deepEqual(result.query.getAll('mpReturnOrigin'), [returnOrigin])
  126. assert.deepEqual(
  127. [...cleaned],
  128. [
  129. ['copyFrom', 'q1'],
  130. ['platform', 'h5']
  131. ]
  132. )
  133. })
  134. test('a native launch without return origin removes stale bridge metadata and retains business data', async () => {
  135. const result = await open(
  136. '/m/crm/customer?tab=follow&mpReturnOrigin=https%3A%2F%2Fold.example.test&mpTicket=old'
  137. )
  138. const cleaned = assertBridge(result, '/m/crm/customer')
  139. assert.deepEqual([...cleaned], [['tab', 'follow']])
  140. })
  141. test('an empty existing query delimiter yields exactly one hash query', async () => {
  142. const result = await open('/m/crm/customer?')
  143. assertBridge(result, '/m/crm/customer')
  144. })
  145. test('existing bounded path decoding still preserves encoded business query data', () => {
  146. const code = declarations
  147. .filter(
  148. (node) =>
  149. ts.isFunctionDeclaration(node) &&
  150. ['normalizePath', 'isAllowedPath'].includes(node.name?.text || '')
  151. )
  152. .map((node) => node.getText(ast))
  153. .join('\n')
  154. const { normalizePath, isAllowedPath } = new Function(
  155. `${code}\nreturn { normalizePath, isAllowedPath }`
  156. )()
  157. const path = '/m/crm/quote/form?copyFrom=q%2B1&tab=follow'
  158. for (const input of [
  159. path,
  160. encodeURIComponent(path),
  161. encodeURIComponent(encodeURIComponent(path))
  162. ]) {
  163. assert.equal(normalizePath(input), path)
  164. assert.equal(isAllowedPath(normalizePath(input)), true)
  165. }
  166. assert.equal(isAllowedPath('/crm/quote/form'), false)
  167. })