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