auth-session.test.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import assert from 'node:assert/strict'
  2. import { readFile } from 'node:fs/promises'
  3. import { fileURLToPath, URL } from 'node:url'
  4. import { setTimeout } from 'node:timers'
  5. import { after, test } from 'node:test'
  6. import { createServer } from 'vite'
  7. import { createApp, createRenderer, nextTick } from 'vue'
  8. import { parse, compileScript } from 'vue/compiler-sfc'
  9. import { createPinia } from 'pinia'
  10. import persistedstate from 'pinia-plugin-persistedstate'
  11. // Keep the real store, router guards and Axios interceptors; replace UI and transport only.
  12. const cache = new Map()
  13. globalThis.localStorage = {
  14. getItem: (key) => cache.get(key) ?? null,
  15. setItem: (key, value) => cache.set(key, value),
  16. }
  17. globalThis.document = { title: '' }
  18. globalThis.history = { state: {} }
  19. globalThis.location = { origin: 'https://test.invalid' }
  20. const server = await createServer({
  21. configFile: false,
  22. resolve: { alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) } },
  23. server: { middlewareMode: true, watch: null },
  24. plugins: [
  25. {
  26. name: 'auth-test-environment',
  27. enforce: 'pre',
  28. resolveId(id) {
  29. if (id === 'vant') return '\0auth-test-vant'
  30. if (id === '/tests/login-component.ts') return id
  31. },
  32. async load(id) {
  33. if (id === '\0auth-test-vant')
  34. return 'export const showToast = () => {}; export const showFailToast = showToast; export const showSuccessToast = showToast; export const showLoadingToast = () => ({ close() {} })'
  35. if (id === '/tests/login-component.ts') {
  36. const source = await readFile(
  37. new URL('../src/views/login/index.vue', import.meta.url),
  38. 'utf8',
  39. )
  40. const { descriptor } = parse(source)
  41. // Execute the actual SFC setup/watch logic without rendering Vant or a browser DOM.
  42. return compileScript(descriptor, { id: 'login-test' }).content.replace(
  43. /import ModernDialog from '[^']+'/,
  44. 'const ModernDialog = {}',
  45. )
  46. }
  47. },
  48. transform(code, id) {
  49. if (id.endsWith('/src/router/index.ts'))
  50. return code.replaceAll('createWebHistory', 'createMemoryHistory')
  51. if (id.endsWith('/src/router/routes.ts'))
  52. return code.replace(/\(\) => import\('[^']+'\)/g, '{ render: () => null }')
  53. },
  54. },
  55. ],
  56. })
  57. after(() => server.close())
  58. const { useUserStore } = await server.ssrLoadModule('/src/stores/modules/user.ts')
  59. const { store } = await server.ssrLoadModule('/src/stores/index.ts')
  60. createApp({}).use(store)
  61. const user = useUserStore(store)
  62. const { default: router } = await server.ssrLoadModule('/src/router/index.ts')
  63. const { Request, HttpError } = await server.ssrLoadModule('/src/services/request/index.ts')
  64. const { default: sharedHttp } = await server.ssrLoadModule('/src/services/index.ts')
  65. const { default: Login } = await server.ssrLoadModule('/tests/login-component.ts')
  66. const renderer = createRenderer({
  67. createComment: () => ({}),
  68. insert() {},
  69. remove() {},
  70. parentNode: () => null,
  71. nextSibling: () => null,
  72. })
  73. const http = new Request()
  74. const response = (config, data, status = 200) => ({
  75. config,
  76. data,
  77. status,
  78. statusText: '',
  79. headers: {},
  80. })
  81. const settle = async () => {
  82. await new Promise((resolve) => setTimeout(resolve, 0))
  83. await nextTick()
  84. }
  85. const login = async () => {
  86. await router.replace('/login?pushRecordId=record-a')
  87. user.setAccessToken('token-a')
  88. await router.replace('/invoice-information')
  89. }
  90. test('web-view recreation restores the token and its original deadline from persistent storage', async () => {
  91. await login()
  92. const deadline = user.tokenExpiresAt
  93. const freshPinia = createPinia().use(persistedstate)
  94. createApp({}).use(freshPinia)
  95. const restored = useUserStore(freshPinia)
  96. assert.equal(restored.access_token, 'token-a')
  97. assert.equal(restored.pushRecordId, 'record-a')
  98. assert.equal(restored.tokenExpiresAt, deadline)
  99. assert.equal(restored.hasValidSession(), true)
  100. restored.$dispose()
  101. })
  102. test('12-hour default and backend expires_in are absolute deadlines and do not slide on reuse', () => {
  103. const now = Date.now()
  104. user.setAccessToken('token-a')
  105. assert.ok(user.tokenExpiresAt >= now + 12 * 60 * 60 * 1000)
  106. assert.ok(user.tokenExpiresAt < now + 12 * 60 * 60 * 1000 + 1000)
  107. user.setAccessToken('token-a', '60')
  108. const deadline = user.tokenExpiresAt
  109. assert.ok(deadline < Date.now() + 61000)
  110. user.setPushRecordId('record-a')
  111. assert.equal(user.hasValidSession(), true)
  112. assert.equal(user.tokenExpiresAt, deadline)
  113. user.setAccessToken('token-a', 0)
  114. assert.equal(user.hasValidSession(), false)
  115. assert.equal(JSON.parse(cache.get('user-store')).access_token, '')
  116. })
  117. test('record ID alone cannot enter a protected route; expiry preserves the login link', async () => {
  118. await login()
  119. user.tokenExpiresAt = Date.now() - 1
  120. await router.replace('/identity-upload')
  121. assert.equal(router.currentRoute.value.path, '/login')
  122. assert.equal(router.currentRoute.value.query.pushRecordId, 'record-a')
  123. assert.equal(user.access_token, '')
  124. await router.replace('/invoice-information')
  125. assert.equal(router.currentRoute.value.path, '/login')
  126. })
  127. test('same link retains authentication; another record and explicit rejection clear it', async () => {
  128. await login()
  129. await router.replace('/login?pushRecordId=record-a')
  130. assert.equal(user.access_token, 'token-a')
  131. await router.replace('/login?pushRecordId=record-b')
  132. assert.equal(user.access_token, '')
  133. user.setAccessToken('token-b')
  134. await router.replace('/login?pushRecordId=record-b&from=reject')
  135. assert.equal(user.access_token, '')
  136. })
  137. for (const status of [401, 424]) {
  138. test(`HTTP ${status} clears persisted credentials and returns to the original login link`, async () => {
  139. await login()
  140. await assert.rejects(
  141. http.get({
  142. url: '/protected',
  143. adapter: async (config) => {
  144. assert.equal(config.headers.Authorization, 'Bearer token-a')
  145. throw { config, response: response(config, { code: 1, msg: 'expired' }, status) }
  146. },
  147. }),
  148. HttpError,
  149. )
  150. await settle()
  151. assert.equal(user.access_token, '')
  152. assert.equal(JSON.parse(cache.get('user-store')).access_token, '')
  153. assert.equal(router.currentRoute.value.query.pushRecordId, 'record-a')
  154. })
  155. }
  156. test('HTTP 200 with an authentication error also invalidates the session', async () => {
  157. await login()
  158. await assert.rejects(
  159. http.get({ url: '/protected', adapter: async (config) => response(config, { code: '401' }) }),
  160. HttpError,
  161. )
  162. await settle()
  163. assert.equal(user.access_token, '')
  164. assert.equal(router.currentRoute.value.path, '/login')
  165. })
  166. test('late unauthorized response from an old token cannot clear a new session', async () => {
  167. await login()
  168. await assert.rejects(
  169. http.get({
  170. url: '/protected',
  171. adapter: async (config) => {
  172. user.setAccessToken('new-token')
  173. throw { config, response: response(config, {}, 401) }
  174. },
  175. }),
  176. HttpError,
  177. )
  178. assert.equal(user.access_token, 'new-token')
  179. assert.equal(JSON.parse(cache.get('user-store')).access_token, 'new-token')
  180. assert.equal(router.currentRoute.value.path, '/invoice-information')
  181. })
  182. test('network failure does not discard a valid session', async () => {
  183. await login()
  184. await assert.rejects(
  185. http.get({
  186. url: '/protected',
  187. adapter: async (config) => {
  188. throw { config, request: {} }
  189. },
  190. }),
  191. { code: 'TIMEOUT' },
  192. )
  193. assert.equal(user.access_token, 'token-a')
  194. })
  195. test('login API uses Basic auth and its business 401 remains available to the login form', async () => {
  196. await login()
  197. const res = await http.post({
  198. url: '/auth/login/etssms',
  199. headers: { isToken: false },
  200. adapter: async (config) => {
  201. assert.equal(config.headers.Authorization, 'Basic ZXRzOmV0cw==')
  202. return response(config, { code: 401, msg: 'registration required' })
  203. },
  204. })
  205. assert.equal(res.code, 401)
  206. assert.equal(user.access_token, 'token-a')
  207. })
  208. test('expiry while a protected page is open stops the request before transport', async () => {
  209. await login()
  210. user.tokenExpiresAt = Date.now() - 1
  211. await assert.rejects(
  212. http.get({
  213. url: '/protected',
  214. adapter: async () => {
  215. assert.fail('expired credentials must not reach the backend')
  216. },
  217. }),
  218. { code: 401 },
  219. )
  220. await settle()
  221. assert.equal(router.currentRoute.value.path, '/login')
  222. })
  223. for (const submitted of [false, true]) {
  224. test(`login entry restores a valid session to ${submitted ? 'submitted' : 'in-progress'} business flow`, async () => {
  225. await login()
  226. await router.replace('/login?pushRecordId=record-a')
  227. const urls = []
  228. sharedHttp.instance.defaults.adapter = async (config) => {
  229. urls.push(config.url)
  230. const data = config.url.endsWith('/pub')
  231. ? { projectName: 'Test', cityCode: '1', needFaceId: true }
  232. : { invoiceStatus: submitted ? 'FINISHED' : '', eventStatus: submitted ? 'PAID' : '' }
  233. return response(config, { code: 0, data })
  234. }
  235. const app = renderer
  236. .createApp({ ...Login, render: () => null })
  237. .use(store)
  238. .use(router)
  239. app.mount({})
  240. try {
  241. await settle()
  242. assert.equal(
  243. router.currentRoute.value.path,
  244. submitted ? '/login-success' : '/invoice-information',
  245. )
  246. assert.equal(user.access_token, 'token-a')
  247. assert.equal(
  248. urls.some((url) => url.includes('/auth/login')),
  249. false,
  250. )
  251. assert.equal(user.needFaceId, true)
  252. } finally {
  253. app.unmount()
  254. }
  255. })
  256. }
  257. test('login entry can retry a failed recovery without asking for credentials again', async () => {
  258. await login()
  259. await router.replace('/login?pushRecordId=record-a')
  260. let failStatus = true
  261. sharedHttp.instance.defaults.adapter = async (config) => {
  262. if (config.url.endsWith('/get-status') && failStatus) throw { config, request: {} }
  263. return response(config, { code: 0, data: { projectName: 'Test' } })
  264. }
  265. const app = renderer
  266. .createApp({ ...Login, render: () => null })
  267. .use(store)
  268. .use(router)
  269. const vm = app.mount({})
  270. try {
  271. await settle()
  272. assert.equal(user.access_token, 'token-a')
  273. assert.equal(router.currentRoute.value.path, '/login')
  274. failStatus = false
  275. await vm.$.setupState.initializeLogin()
  276. assert.equal(router.currentRoute.value.path, '/invoice-information')
  277. } finally {
  278. app.unmount()
  279. }
  280. })