| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- 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()
- }
- })
|