Ver Fonte

Merge branch 'feature-loginStatus-260917' into pre

yuanmingze há 2 dias atrás
pai
commit
8d59b7f297

+ 1 - 0
package.json

@@ -7,6 +7,7 @@
     "node": "^20.19.0 || >=22.12.0"
   },
   "scripts": {
+    "test:auth": "node --test tests/auth-session.test.mjs",
     "dev": "vite --host",
     "build": "run-p type-check \"build-only {@}\" --",
     "pre": "run-p type-check \"build-only --mode pre\" --",

+ 9 - 2
src/router/index.ts

@@ -12,13 +12,20 @@ const router = createRouter({
 // 路由守卫
 router.beforeEach((to, from, next) => {
   const userStore = useUserStoreWithOut()
-  const token = userStore.access_token
+  if (to.name === 'Login' || to.meta.requiresAuth) {
+    if (typeof to.query.pushRecordId === 'string') {
+      userStore.setPushRecordId(to.query.pushRecordId)
+    }
+    if (to.name === 'Login' && to.query.from === 'reject') userStore.LogOut()
+  }
+  const token = userStore.hasValidSession()
   const pushRecordId = userStore.pushRecordId
 
-  if (to.meta.requiresAuth && !token && !pushRecordId) {
+  if (to.meta.requiresAuth && (!token || !pushRecordId)) {
     // 未登录时跳转登录页
     next({
       path: '/login',
+      query: pushRecordId ? { pushRecordId } : {},
     })
   } else {
     next()

+ 4 - 0
src/services/modules/login/index.ts

@@ -5,6 +5,7 @@ import type { PushRecordIdRequest } from '../invoiceInformation/type.d'
 export const getPubNameApi = (data: PushRecordIdRequest) => {
   return http.get({
     url: '/admin/invoice-record/pub',
+    headers: { isToken: false },
     params: data,
   })
 }
@@ -12,6 +13,7 @@ export const getPubNameApi = (data: PushRecordIdRequest) => {
 export const sendEtsSmsApi = (data: SendTtsSmsRequest) => {
   return http.post({
     url: '/admin/invoice-order/send-ets-sms',
+    headers: { isToken: false },
     data: data,
   })
 }
@@ -19,6 +21,7 @@ export const sendEtsSmsApi = (data: SendTtsSmsRequest) => {
 export const loginEtssmsApi = (data: LoginEtssmsRequest) => {
   return http.post({
     url: '/auth/login/etssms',
+    headers: { isToken: false },
     params: data,
   })
 }
@@ -26,6 +29,7 @@ export const loginEtssmsApi = (data: LoginEtssmsRequest) => {
 export const registerUrlApi = (params: RegisterUrlRequest) => {
   return http.get({
     url: '/admin/invoice/register-url',
+    headers: { isToken: false },
     params: params,
   })
 }

+ 47 - 22
src/services/request/index.ts

@@ -83,6 +83,30 @@ const abortAllRequests = () => {
   }
 }
 
+const redirectToLogin = () => {
+  const userStore = useUserStoreWithOut()
+  const pushRecordId = userStore.pushRecordId
+  userStore.LogOut()
+  abortAllRequests()
+  showToast('您的登录状态已失效,请重新登录')
+  void router.replace({ path: '/login', query: pushRecordId ? { pushRecordId } : {} })
+}
+
+const handleUnauthorized = (response: AxiosResponse) => {
+  const userStore = useUserStoreWithOut()
+  const authorization = response.config.headers?.Authorization
+  // 登录/验证码接口的 401 由页面处理;旧请求的迟到响应不能注销新会话。
+  if (userStore.access_token && authorization === `Bearer ${userStore.access_token}`) {
+    redirectToLogin()
+  }
+  throw new HttpError(response.data?.msg || '您的登录状态已失效,请重新登录', {
+    code: response.data?.code || response.status,
+    status: response.status,
+    data: response.data?.data,
+    response: response.data,
+  })
+}
+
 /* -------------------------------------------------------------------------- */
 /*                              网络错误统一处理                                */
 /* -------------------------------------------------------------------------- */
@@ -96,23 +120,13 @@ const handleNetworkError = (response: AxiosResponse, silent?: boolean) => {
   // 默认错误信息
   let message = '未知错误'
 
-  const userStore = useUserStoreWithOut()
-
-  // 先处理401登录失效问题
-  if (status === 401) {
-    showToast('您的登录状态已失效,请重新登录')
-    abortAllRequests()
-    const pushRecordId = userStore.pushRecordId
-    // 🔁 跳转到登录页
-    setTimeout(() => {
-      useUserStoreWithOut().LogOut()
-      router.replace({
-        path: '/login',
-        query: {
-          pushRecordId: pushRecordId,
-        },
-      })
-    }, 1500)
+  if (
+    status === 401 ||
+    status === 424 ||
+    Number(backendCode) === 401 ||
+    Number(backendCode) === 424
+  ) {
+    handleUnauthorized(response)
   }
 
   // ✅ 优先使用后端业务错误(即 code !== 0)
@@ -146,11 +160,6 @@ const handleNetworkError = (response: AxiosResponse, silent?: boolean) => {
     case 408:
       message = '请求超时'
       break
-    case 424:
-      message = 'Token 无效'
-      abortAllRequests()
-      useUserStoreWithOut().LogOut()
-      break
     case 500:
       message = '服务器错误'
       break
@@ -185,6 +194,14 @@ const onRequest = (config: CustomAxiosRequestConfig): CustomAxiosRequestConfig =
   config.headers = config.headers || {}
 
   const userStore = useUserStoreWithOut()
+  if (
+    !userStore.hasValidSession() &&
+    config.headers.isToken !== false &&
+    router.currentRoute.value.meta.requiresAuth
+  ) {
+    redirectToLogin()
+    throw new HttpError('您的登录状态已失效,请重新登录', { code: 401 })
+  }
   const { tenant_id, access_token } = userStore
 
   const requestKey = generateRequestKey(config)
@@ -240,6 +257,13 @@ const onResponse = (response: AxiosResponse<ServiceResponse>) => {
   // const customConfig = response.config as CustomAxiosRequestConfig
 
   const res = response.data
+  if (
+    response.config.headers?.isToken !== false &&
+    response.config.headers?.isToken !== 'false' &&
+    (Number(res?.code) === 401 || Number(res?.code) === 424)
+  ) {
+    handleUnauthorized(response)
+  }
   // const { code, msg } = res
 
   // if (SUCCESS_CODE_WHITE_LIST.includes(code)) {
@@ -264,6 +288,7 @@ const onResponse = (response: AxiosResponse<ServiceResponse>) => {
 }
 
 const onResponseError = (error: any) => {
+  if (error instanceof HttpError) throw error
   const key = generateRequestKey(error.config || {})
   clearRequest(key)
 

+ 23 - 3
src/stores/modules/user.ts

@@ -3,6 +3,7 @@ import { store } from '@/stores'
 
 interface UserState {
   access_token: string
+  tokenExpiresAt: number
   tenant_id: string
   pushRecordId: string
   needFaceId: boolean
@@ -11,31 +12,50 @@ interface UserState {
 export const useUserStore = defineStore('user', {
   state: (): UserState => ({
     access_token: '',
+    tokenExpiresAt: 0,
     tenant_id: '1',
     pushRecordId: '',
     needFaceId: false,
   }),
 
   actions: {
-    setAccessToken(token: string) {
+    setAccessToken(token: string, expiresIn?: number | string) {
+      // expires_in 的单位为秒;未返回时使用后端约定的 12 小时。
+      const seconds = expiresIn == null || expiresIn === '' ? NaN : Number(expiresIn)
       this.access_token = token
+      this.tokenExpiresAt =
+        Date.now() + (Number.isFinite(seconds) && seconds >= 0 ? seconds : 12 * 60 * 60) * 1000
+      this.$persist()
+    },
+    hasValidSession() {
+      if (!this.access_token) return false
+      if (!Number.isFinite(this.tokenExpiresAt) || Date.now() >= this.tokenExpiresAt) {
+        this.LogOut()
+        return false
+      }
+      return true
     },
     setPushRecordId(id: string) {
+      // 当前链接没有可核对的用户 ID,不能把旧业务的 token 用到另一条链接。
+      if (id !== this.pushRecordId) this.LogOut()
       this.pushRecordId = id
+      this.$persist()
     },
     setNeedFaceId(flag: boolean) {
       this.needFaceId = flag
     },
     LogOut() {
       this.access_token = ''
+      this.tokenExpiresAt = 0
       this.needFaceId = false
+      this.$persist()
     },
   },
 
   persist: {
     key: 'user-store',
-    storage: sessionStorage,
-    pick: ['access_token', 'needFaceId', 'tenant_id', 'pushRecordId'],
+    storage: localStorage,
+    pick: ['access_token', 'tokenExpiresAt', 'needFaceId', 'tenant_id', 'pushRecordId'],
   },
 })
 

+ 101 - 66
src/views/login/index.vue

@@ -2,7 +2,14 @@
   <div class="login">
     <van-nav-bar title="自然人开票" />
 
-    <div class="login-content">
+    <div v-if="restoringSession" class="login-content">
+      <van-loading vertical>正在恢复登录状态…</van-loading>
+    </div>
+    <div v-else-if="userStore.access_token && formData.pushRecordId" class="login-content">
+      <div class="text">暂时无法恢复开票流程,请重试。</div>
+      <div class="login-btn btn" @click="initializeLogin">重试</div>
+    </div>
+    <div v-else class="login-content">
       <div class="text">感谢您参加{{ pubName }}项目,请您登录系统开具发票。</div>
 
       <div class="bold-text">已注册电子税务局</div>
@@ -109,7 +116,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onBeforeMount, onActivated } from 'vue'
+import { ref, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { showFailToast, showSuccessToast, showToast, showLoadingToast } from 'vant'
 import {
@@ -132,6 +139,7 @@ import ModernDialog from '@/components/ModernDialog.vue'
 const userStore = useUserStore()
 const router = useRouter()
 const route = useRoute()
+const restoringSession = ref(false)
 
 const passwordType = ref<'text' | 'password'>('password')
 const switchPasswordType = () => {
@@ -245,6 +253,37 @@ const loginCancel = () => {
 const loginConfirm = () => {
   registerUrl()
 }
+
+// 首次登录与缓存恢复使用同一套业务状态判断,避免已提交业务重新进入开票页。
+const continueInvoice = async (pushRecordId: string) => {
+  const token = userStore.access_token
+  const statusRes = await getStatusApi({ pushRecordId })
+  if (
+    route.name !== 'Login' ||
+    userStore.pushRecordId !== pushRecordId ||
+    userStore.access_token !== token ||
+    !userStore.hasValidSession()
+  )
+    return
+  if (Number(statusRes.code) !== 0 || !statusRes.data) {
+    throw new Error(statusRes.msg || '获取开票状态失败,请重试')
+  }
+  const isSubmitStatusMap = ['PENDING', 'FINISHED', 'RED_INK_ENTRY', 'NOT_APPROVED', 'DISCARD']
+  const isSubmitEventStatusMap = [
+    'SUBMITTED',
+    'PENDING_PAYMENT',
+    'PAID',
+    'PENDING_FINAL_INVOICING',
+    'INVOICE_FAILED',
+    'FINAL_INVOICE_SUBMITTED',
+    'INVOICE_DOWNLOADABLE',
+  ]
+  const { invoiceStatus, eventStatus } = statusRes.data
+  const submitted =
+    isSubmitStatusMap.includes(invoiceStatus) && isSubmitEventStatusMap.includes(eventStatus)
+  await router.replace({ path: submitted ? '/login-success' : '/invoice-information' })
+}
+
 /* 登录逻辑 */
 const loginBtn = useDebounceFn(async () => {
   if (!formData.mobile) {
@@ -269,47 +308,16 @@ const loginBtn = useDebounceFn(async () => {
     duration: 0,
   })
 
-  // 已提交状态映射
-  const isSubmitStatusMap = ['PENDING', 'FINISHED', 'RED_INK_ENTRY', 'NOT_APPROVED', 'DISCARD']
-  const isSubmitEventStatusMap = [
-    'SUBMITTED',
-    'PENDING_PAYMENT',
-    'PAID',
-    'PENDING_FINAL_INVOICING',
-    'INVOICE_FAILED',
-    'FINAL_INVOICE_SUBMITTED',
-    'INVOICE_DOWNLOADABLE',
-  ]
-
+  const pushRecordId = formData.pushRecordId
   try {
     const res: any = await loginEtssmsApi(formData)
+    if (route.name !== 'Login' || userStore.pushRecordId !== pushRecordId) return
     if (res?.access_token) {
-      userStore.setAccessToken(res.access_token)
-      toast.close()
-      const statusToast = showLoadingToast({
-        message: '获取状态中…',
-        forbidClick: true,
-        duration: 0,
-      })
-      try {
-        const statusRes = await getStatusApi({
-          pushRecordId: formData.pushRecordId,
-        })
-        // 修改认证下的状态内容,如果已经提交,跳转到成功页
-        if (statusRes.code == 0) {
-          const { invoiceStatus, eventStatus } = statusRes.data
-          const isInvoiceSubmitted = invoiceStatus && isSubmitStatusMap.includes(invoiceStatus)
-          const isEventSubmitted = eventStatus && isSubmitEventStatusMap.includes(eventStatus)
-          if (isInvoiceSubmitted && isEventSubmitted) {
-            return router.replace({
-              path: '/login-success',
-            })
-          }
-        }
-        router.replace({ path: '/invoice-information' })
-      } finally {
-        statusToast.close()
-      }
+      userStore.setAccessToken(res.access_token, res.expires_in)
+      formData.password = ''
+      formData.code = ''
+      sendEtsSms.password = ''
+      if (await getPubName(pushRecordId)) await continueInvoice(pushRecordId)
     } else if (res.code == 401) {
       loginMessage.value = res.msg
       loginDialog.value = true
@@ -317,11 +325,9 @@ const loginBtn = useDebounceFn(async () => {
       changePasswordDialog.value = true
     }
   } catch (err: any) {
+    showFailToast(err?.message || '登录失败,请重试')
+  } finally {
     toast.close()
-    const { code, message } = err || {}
-    if (code && message) {
-      showFailToast(message)
-    }
   }
 }, 800)
 
@@ -347,23 +353,27 @@ const registerUrl = useDebounceFn(async () => {
 // 项目名称
 const pubName = ref('')
 
-const getPubName = async () => {
+const getPubName = async (pushRecordId: string) => {
   try {
     const res = await getPubNameApi({
-      pushRecordId: sendEtsSms.pushRecordId,
+      pushRecordId,
     })
+    if (route.name !== 'Login' || userStore.pushRecordId !== pushRecordId) return false
     if (res.code === 0) {
       pubName.value = res.data.projectName
       registerUrlParams.value.areaId = res.data.cityCode
       userStore.setNeedFaceId(res.data.needFaceId)
-      registerUrlParams.value.pushRecordId = sendEtsSms.pushRecordId
+      registerUrlParams.value.pushRecordId = pushRecordId
+      return true
     } else {
       codeMessage.value = res.msg
       showDialog.value = true
+      if (userStore.access_token) showFailToast(res.msg || '获取项目信息失败,请重试')
     }
-  } catch (err) {
-    console.log('err', err)
+  } catch (err: any) {
+    showFailToast(err?.message || '获取项目信息失败,请重试')
   }
+  return false
 }
 /* 协议跳转 */
 const toAgreement = (type: string) => {
@@ -375,7 +385,9 @@ const toAgreement = (type: string) => {
 
 const resetLoginState = () => {
   formData.mobile = ''
+  formData.password = ''
   formData.code = ''
+  sendEtsSms.password = ''
   agree.value = false
   isCounting.value = false
   countDown.value = 60
@@ -390,36 +402,59 @@ const resetLoginState = () => {
   }
 }
 
-onActivated(() => {
+let initializationId = 0
+const initializeLogin = async () => {
+  if (route.name !== 'Login') return
+  const currentInitialization = ++initializationId
   if (route.query.from === 'reject') {
     resetLoginState()
-    router.replace({
+    return router.replace({
       path: route.path,
       query: {
-        pushRecordId: route.query.pushRecordId,
+        pushRecordId: userStore.pushRecordId,
       },
     })
   }
-})
-
-onBeforeMount(() => {
-  const userStore = useUserStore()
-  userStore.$reset()
-  localStorage.removeItem('user-store')
-  registerUrlParams.value.returnUrl = `${location.origin}/h5${route.fullPath}`
-  const pushRecordId = route.query.pushRecordId
+  const pushRecordId =
+    route.query.pushRecordId === undefined
+      ? userStore.pushRecordId
+      : typeof route.query.pushRecordId === 'string'
+        ? route.query.pushRecordId
+        : ''
   if (!pushRecordId) {
-    userStore.setPushRecordId('')
     formData.pushRecordId = ''
     sendEtsSms.pushRecordId = ''
     showToast('登录链接存在不完整或者输入错误')
     return
   }
-  userStore.setPushRecordId(pushRecordId as string)
-  sendEtsSms.pushRecordId = pushRecordId as string
-  formData.pushRecordId = pushRecordId as string
-  getPubName()
-})
+  if (formData.pushRecordId !== pushRecordId) resetLoginState()
+  userStore.setPushRecordId(pushRecordId)
+  sendEtsSms.pushRecordId = pushRecordId
+  formData.pushRecordId = pushRecordId
+  registerUrlParams.value.returnUrl = `${location.origin}${import.meta.env.BASE_URL}login?pushRecordId=${encodeURIComponent(pushRecordId)}`
+  restoringSession.value = userStore.hasValidSession()
+  try {
+    const ready = await getPubName(pushRecordId)
+    if (currentInitialization !== initializationId) return
+    if (ready && userStore.hasValidSession()) await continueInvoice(pushRecordId)
+  } catch (err: any) {
+    // 网络异常保留 token 供重试;真正的失效由请求拦截器统一清理。
+    showFailToast(err?.message || '恢复登录状态失败,请重试')
+  } finally {
+    if (currentInitialization === initializationId) restoringSession.value = false
+  }
+}
+
+// KeepAlive 下同一路由的新链接、协议返回及失效回跳都需要重新检查。
+watch(
+  () => {
+    return route.name === 'Login' ? route.fullPath : ''
+  },
+  () => {
+    void initializeLogin()
+  },
+  { immediate: true },
+)
 </script>
 
 <style lang="scss" scoped>

+ 293 - 0
tests/auth-session.test.mjs

@@ -0,0 +1,293 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath, URL } from 'node:url'
+import { setTimeout } from 'node:timers'
+import { after, test } from 'node:test'
+import { createServer } from 'vite'
+import { createApp, createRenderer, nextTick } from 'vue'
+import { parse, compileScript } from 'vue/compiler-sfc'
+import { createPinia } from 'pinia'
+import persistedstate from 'pinia-plugin-persistedstate'
+
+// Keep the real store, router guards and Axios interceptors; replace UI and transport only.
+const cache = new Map()
+globalThis.localStorage = {
+  getItem: (key) => cache.get(key) ?? null,
+  setItem: (key, value) => cache.set(key, value),
+}
+globalThis.document = { title: '' }
+globalThis.history = { state: {} }
+globalThis.location = { origin: 'https://test.invalid' }
+const server = await createServer({
+  configFile: false,
+  resolve: { alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) } },
+  server: { middlewareMode: true, watch: null },
+  plugins: [
+    {
+      name: 'auth-test-environment',
+      enforce: 'pre',
+      resolveId(id) {
+        if (id === 'vant') return '\0auth-test-vant'
+        if (id === '/tests/login-component.ts') return id
+      },
+      async load(id) {
+        if (id === '\0auth-test-vant')
+          return 'export const showToast = () => {}; export const showFailToast = showToast; export const showSuccessToast = showToast; export const showLoadingToast = () => ({ close() {} })'
+        if (id === '/tests/login-component.ts') {
+          const source = await readFile(
+            new URL('../src/views/login/index.vue', import.meta.url),
+            'utf8',
+          )
+          const { descriptor } = parse(source)
+          // Execute the actual SFC setup/watch logic without rendering Vant or a browser DOM.
+          return compileScript(descriptor, { id: 'login-test' }).content.replace(
+            /import ModernDialog from '[^']+'/,
+            'const ModernDialog = {}',
+          )
+        }
+      },
+      transform(code, id) {
+        if (id.endsWith('/src/router/index.ts'))
+          return code.replaceAll('createWebHistory', 'createMemoryHistory')
+        if (id.endsWith('/src/router/routes.ts'))
+          return code.replace(/\(\) => import\('[^']+'\)/g, '{ render: () => null }')
+      },
+    },
+  ],
+})
+after(() => server.close())
+const { useUserStore } = await server.ssrLoadModule('/src/stores/modules/user.ts')
+const { store } = await server.ssrLoadModule('/src/stores/index.ts')
+createApp({}).use(store)
+const user = useUserStore(store)
+const { default: router } = await server.ssrLoadModule('/src/router/index.ts')
+const { Request, HttpError } = await server.ssrLoadModule('/src/services/request/index.ts')
+const { default: sharedHttp } = await server.ssrLoadModule('/src/services/index.ts')
+const { default: Login } = await server.ssrLoadModule('/tests/login-component.ts')
+const renderer = createRenderer({
+  createComment: () => ({}),
+  insert() {},
+  remove() {},
+  parentNode: () => null,
+  nextSibling: () => null,
+})
+const http = new Request()
+const response = (config, data, status = 200) => ({
+  config,
+  data,
+  status,
+  statusText: '',
+  headers: {},
+})
+const settle = async () => {
+  await new Promise((resolve) => setTimeout(resolve, 0))
+  await nextTick()
+}
+const login = async () => {
+  await router.replace('/login?pushRecordId=record-a')
+  user.setAccessToken('token-a')
+  await router.replace('/invoice-information')
+}
+
+test('web-view recreation restores the token and its original deadline from persistent storage', async () => {
+  await login()
+  const deadline = user.tokenExpiresAt
+  const freshPinia = createPinia().use(persistedstate)
+  createApp({}).use(freshPinia)
+  const restored = useUserStore(freshPinia)
+  assert.equal(restored.access_token, 'token-a')
+  assert.equal(restored.pushRecordId, 'record-a')
+  assert.equal(restored.tokenExpiresAt, deadline)
+  assert.equal(restored.hasValidSession(), true)
+  restored.$dispose()
+})
+
+test('12-hour default and backend expires_in are absolute deadlines and do not slide on reuse', () => {
+  const now = Date.now()
+  user.setAccessToken('token-a')
+  assert.ok(user.tokenExpiresAt >= now + 12 * 60 * 60 * 1000)
+  assert.ok(user.tokenExpiresAt < now + 12 * 60 * 60 * 1000 + 1000)
+  user.setAccessToken('token-a', '60')
+  const deadline = user.tokenExpiresAt
+  assert.ok(deadline < Date.now() + 61000)
+  user.setPushRecordId('record-a')
+  assert.equal(user.hasValidSession(), true)
+  assert.equal(user.tokenExpiresAt, deadline)
+  user.setAccessToken('token-a', 0)
+  assert.equal(user.hasValidSession(), false)
+  assert.equal(JSON.parse(cache.get('user-store')).access_token, '')
+})
+
+test('record ID alone cannot enter a protected route; expiry preserves the login link', async () => {
+  await login()
+  user.tokenExpiresAt = Date.now() - 1
+  await router.replace('/identity-upload')
+  assert.equal(router.currentRoute.value.path, '/login')
+  assert.equal(router.currentRoute.value.query.pushRecordId, 'record-a')
+  assert.equal(user.access_token, '')
+  await router.replace('/invoice-information')
+  assert.equal(router.currentRoute.value.path, '/login')
+})
+
+test('same link retains authentication; another record and explicit rejection clear it', async () => {
+  await login()
+  await router.replace('/login?pushRecordId=record-a')
+  assert.equal(user.access_token, 'token-a')
+  await router.replace('/login?pushRecordId=record-b')
+  assert.equal(user.access_token, '')
+  user.setAccessToken('token-b')
+  await router.replace('/login?pushRecordId=record-b&from=reject')
+  assert.equal(user.access_token, '')
+})
+
+for (const status of [401, 424]) {
+  test(`HTTP ${status} clears persisted credentials and returns to the original login link`, async () => {
+    await login()
+    await assert.rejects(
+      http.get({
+        url: '/protected',
+        adapter: async (config) => {
+          assert.equal(config.headers.Authorization, 'Bearer token-a')
+          throw { config, response: response(config, { code: 1, msg: 'expired' }, status) }
+        },
+      }),
+      HttpError,
+    )
+    await settle()
+    assert.equal(user.access_token, '')
+    assert.equal(JSON.parse(cache.get('user-store')).access_token, '')
+    assert.equal(router.currentRoute.value.query.pushRecordId, 'record-a')
+  })
+}
+
+test('HTTP 200 with an authentication error also invalidates the session', async () => {
+  await login()
+  await assert.rejects(
+    http.get({ url: '/protected', adapter: async (config) => response(config, { code: '401' }) }),
+    HttpError,
+  )
+  await settle()
+  assert.equal(user.access_token, '')
+  assert.equal(router.currentRoute.value.path, '/login')
+})
+
+test('late unauthorized response from an old token cannot clear a new session', async () => {
+  await login()
+  await assert.rejects(
+    http.get({
+      url: '/protected',
+      adapter: async (config) => {
+        user.setAccessToken('new-token')
+        throw { config, response: response(config, {}, 401) }
+      },
+    }),
+    HttpError,
+  )
+  assert.equal(user.access_token, 'new-token')
+  assert.equal(JSON.parse(cache.get('user-store')).access_token, 'new-token')
+  assert.equal(router.currentRoute.value.path, '/invoice-information')
+})
+
+test('network failure does not discard a valid session', async () => {
+  await login()
+  await assert.rejects(
+    http.get({
+      url: '/protected',
+      adapter: async (config) => {
+        throw { config, request: {} }
+      },
+    }),
+    { code: 'TIMEOUT' },
+  )
+  assert.equal(user.access_token, 'token-a')
+})
+
+test('login API uses Basic auth and its business 401 remains available to the login form', async () => {
+  await login()
+  const res = await http.post({
+    url: '/auth/login/etssms',
+    headers: { isToken: false },
+    adapter: async (config) => {
+      assert.equal(config.headers.Authorization, 'Basic ZXRzOmV0cw==')
+      return response(config, { code: 401, msg: 'registration required' })
+    },
+  })
+  assert.equal(res.code, 401)
+  assert.equal(user.access_token, 'token-a')
+})
+
+test('expiry while a protected page is open stops the request before transport', async () => {
+  await login()
+  user.tokenExpiresAt = Date.now() - 1
+  await assert.rejects(
+    http.get({
+      url: '/protected',
+      adapter: async () => {
+        assert.fail('expired credentials must not reach the backend')
+      },
+    }),
+    { code: 401 },
+  )
+  await settle()
+  assert.equal(router.currentRoute.value.path, '/login')
+})
+
+for (const submitted of [false, true]) {
+  test(`login entry restores a valid session to ${submitted ? 'submitted' : 'in-progress'} business flow`, async () => {
+    await login()
+    await router.replace('/login?pushRecordId=record-a')
+    const urls = []
+    sharedHttp.instance.defaults.adapter = async (config) => {
+      urls.push(config.url)
+      const data = config.url.endsWith('/pub')
+        ? { projectName: 'Test', cityCode: '1', needFaceId: true }
+        : { invoiceStatus: submitted ? 'FINISHED' : '', eventStatus: submitted ? 'PAID' : '' }
+      return response(config, { code: 0, data })
+    }
+    const app = renderer
+      .createApp({ ...Login, render: () => null })
+      .use(store)
+      .use(router)
+    app.mount({})
+    try {
+      await settle()
+      assert.equal(
+        router.currentRoute.value.path,
+        submitted ? '/login-success' : '/invoice-information',
+      )
+      assert.equal(user.access_token, 'token-a')
+      assert.equal(
+        urls.some((url) => url.includes('/auth/login')),
+        false,
+      )
+      assert.equal(user.needFaceId, true)
+    } finally {
+      app.unmount()
+    }
+  })
+}
+
+test('login entry can retry a failed recovery without asking for credentials again', async () => {
+  await login()
+  await router.replace('/login?pushRecordId=record-a')
+  let failStatus = true
+  sharedHttp.instance.defaults.adapter = async (config) => {
+    if (config.url.endsWith('/get-status') && failStatus) throw { config, request: {} }
+    return response(config, { code: 0, data: { projectName: 'Test' } })
+  }
+  const app = renderer
+    .createApp({ ...Login, render: () => null })
+    .use(store)
+    .use(router)
+  const vm = app.mount({})
+  try {
+    await settle()
+    assert.equal(user.access_token, 'token-a')
+    assert.equal(router.currentRoute.value.path, '/login')
+    failStatus = false
+    await vm.$.setupState.initializeLogin()
+    assert.equal(router.currentRoute.value.path, '/invoice-information')
+  } finally {
+    app.unmount()
+  }
+})