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