2026-07-30 17:59:43 +08:00
|
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import { resolve } from 'node:path'
|
|
|
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
|
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
2026-08-03 18:49:18 +08:00
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
import { useAuthStore } from '@/core/auth/auth'
|
2026-07-30 17:59:43 +08:00
|
|
|
import LoginView from './LoginView.vue'
|
|
|
|
|
|
2026-08-03 18:49:18 +08:00
|
|
|
const loginSource = readFileSync(resolve(process.cwd(), 'src/modules/auth/pages/LoginView.vue'), 'utf8')
|
2026-08-01 12:17:33 +08:00
|
|
|
const formStub = {
|
|
|
|
|
template: '<form><slot /></form>',
|
|
|
|
|
setup(_: unknown, { expose }: { expose: (exposed: { validate: () => Promise<boolean> }) => void }) {
|
|
|
|
|
expose({ validate: async () => true })
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-07-30 17:59:43 +08:00
|
|
|
|
|
|
|
|
function createMemoryStorage(): Storage {
|
|
|
|
|
const values = new Map<string, string>()
|
|
|
|
|
return {
|
|
|
|
|
get length() { return values.size },
|
|
|
|
|
clear: () => values.clear(),
|
|
|
|
|
getItem: (key) => values.get(key) ?? null,
|
|
|
|
|
key: (index) => [...values.keys()][index] ?? null,
|
|
|
|
|
removeItem: (key) => values.delete(key),
|
|
|
|
|
setItem: (key, value) => values.set(key, value),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
describe('LoginView authentication lifecycle', () => {
|
2026-07-30 17:59:43 +08:00
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.stubGlobal('localStorage', createMemoryStorage())
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
it('always renders a blank login form without demo credentials or hints', () => {
|
|
|
|
|
expect(loginSource).toContain("account: ''")
|
|
|
|
|
expect(loginSource).toContain("password: ''")
|
|
|
|
|
expect(loginSource).not.toContain('import.meta.env.DEV')
|
|
|
|
|
expect(loginSource).not.toContain('platform_admin')
|
|
|
|
|
expect(loginSource).not.toContain('ChangeMe123!')
|
|
|
|
|
expect(loginSource).not.toContain('demo-tip')
|
2026-07-30 17:59:43 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-10 17:42:17 +08:00
|
|
|
it('uses the admin design tokens and keeps the brand visible at 320px', () => {
|
|
|
|
|
expect(loginSource).toContain('<h1>柏悦物流管理后台</h1>')
|
|
|
|
|
expect(loginSource).toContain('class="login-mobile-brand"')
|
|
|
|
|
expect(loginSource).toContain('aria-label="平台员工登录"')
|
|
|
|
|
expect(loginSource).toContain('autocomplete="username" maxlength="100" autofocus')
|
|
|
|
|
expect(loginSource).toContain('aria-live="polite"')
|
|
|
|
|
expect(loginSource).toContain('background: var(--cc-surface-muted)')
|
|
|
|
|
expect(loginSource).toContain('border-radius: var(--cc-radius-lg)')
|
|
|
|
|
expect(loginSource).toContain('@media (max-width: 360px)')
|
|
|
|
|
expect(loginSource).not.toContain('让跨境物流管理')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-30 17:59:43 +08:00
|
|
|
it('initializes preview access after login and before redirecting', async () => {
|
|
|
|
|
const pinia = createPinia()
|
|
|
|
|
setActivePinia(pinia)
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
const router = createRouter({
|
|
|
|
|
history: createMemoryHistory(),
|
|
|
|
|
routes: [
|
|
|
|
|
{ path: '/login', component: LoginView },
|
|
|
|
|
{ path: '/', component: { template: '<p>home</p>' } },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
await router.push('/login')
|
|
|
|
|
await router.isReady()
|
|
|
|
|
|
|
|
|
|
const calls: string[] = []
|
|
|
|
|
vi.spyOn(auth, 'login').mockImplementation(async () => { calls.push('login') })
|
|
|
|
|
vi.spyOn(access, 'initializeAfterLogin').mockImplementation(() => { calls.push('access') })
|
|
|
|
|
vi.spyOn(router, 'replace').mockImplementation(async () => { calls.push('replace') })
|
|
|
|
|
const wrapper = mount(LoginView, {
|
|
|
|
|
global: {
|
|
|
|
|
plugins: [pinia, router],
|
|
|
|
|
stubs: {
|
2026-08-01 12:17:33 +08:00
|
|
|
'el-form': formStub,
|
2026-07-30 17:59:43 +08:00
|
|
|
'el-form-item': { template: '<label><slot /></label>' },
|
2026-07-31 15:34:35 +08:00
|
|
|
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
2026-07-30 17:59:43 +08:00
|
|
|
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
const inputs = wrapper.findAll('input')
|
|
|
|
|
await inputs[0]!.setValue('test_admin')
|
|
|
|
|
await inputs[1]!.setValue('password')
|
2026-08-01 12:17:33 +08:00
|
|
|
await wrapper.get('form').trigger('submit')
|
2026-07-30 17:59:43 +08:00
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(calls).toEqual(['login', 'access', 'replace'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['/customers?page=2', '/customers?page=2'],
|
|
|
|
|
['/customers/reviews', '/customers/reviews'],
|
2026-08-05 15:54:59 +08:00
|
|
|
['//example.com', '/dashboard'],
|
|
|
|
|
['https://example.com', '/dashboard'],
|
|
|
|
|
[['/customers', '/orders/first-leg'], '/dashboard'],
|
|
|
|
|
[null, '/dashboard'],
|
2026-07-30 17:59:43 +08:00
|
|
|
])('sanitizes the post-login redirect %j to %s', async (redirect, expected) => {
|
|
|
|
|
const pinia = createPinia()
|
|
|
|
|
setActivePinia(pinia)
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
const router = createRouter({
|
|
|
|
|
history: createMemoryHistory(),
|
|
|
|
|
routes: [
|
|
|
|
|
{ path: '/login', component: LoginView },
|
|
|
|
|
{ path: '/', component: { template: '<p>home</p>' } },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
await router.push({ path: '/login', query: { redirect } })
|
|
|
|
|
await router.isReady()
|
|
|
|
|
vi.spyOn(auth, 'login').mockResolvedValue()
|
|
|
|
|
const replace = vi.spyOn(router, 'replace').mockResolvedValue()
|
|
|
|
|
const wrapper = mount(LoginView, {
|
|
|
|
|
global: {
|
|
|
|
|
plugins: [pinia, router],
|
|
|
|
|
stubs: {
|
2026-08-01 12:17:33 +08:00
|
|
|
'el-form': formStub,
|
2026-07-30 17:59:43 +08:00
|
|
|
'el-form-item': { template: '<label><slot /></label>' },
|
2026-07-31 15:34:35 +08:00
|
|
|
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
2026-07-30 17:59:43 +08:00
|
|
|
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
const inputs = wrapper.findAll('input')
|
|
|
|
|
await inputs[0]!.setValue('test_admin')
|
|
|
|
|
await inputs[1]!.setValue('password')
|
2026-08-01 12:17:33 +08:00
|
|
|
await wrapper.get('form').trigger('submit')
|
2026-07-30 17:59:43 +08:00
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(replace).toHaveBeenCalledWith(expected)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves the form and reports a recoverable error when login fails', async () => {
|
|
|
|
|
const pinia = createPinia()
|
|
|
|
|
setActivePinia(pinia)
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
const router = createRouter({
|
|
|
|
|
history: createMemoryHistory(),
|
|
|
|
|
routes: [
|
|
|
|
|
{ path: '/login', component: LoginView },
|
|
|
|
|
{ path: '/customers', component: { template: '<p>customers</p>' } },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
await router.push('/login')
|
|
|
|
|
await router.isReady()
|
|
|
|
|
vi.spyOn(auth, 'login').mockRejectedValue(new Error('network unavailable'))
|
|
|
|
|
const initialize = vi.spyOn(access, 'initializeAfterLogin')
|
|
|
|
|
const replace = vi.spyOn(router, 'replace')
|
|
|
|
|
const wrapper = mount(LoginView, {
|
|
|
|
|
global: {
|
|
|
|
|
plugins: [pinia, router],
|
|
|
|
|
stubs: {
|
2026-08-01 12:17:33 +08:00
|
|
|
'el-form': formStub,
|
2026-07-30 17:59:43 +08:00
|
|
|
'el-form-item': { template: '<label><slot /></label>' },
|
2026-07-31 15:34:35 +08:00
|
|
|
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
2026-07-30 17:59:43 +08:00
|
|
|
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
const inputs = wrapper.findAll('input')
|
|
|
|
|
await inputs[0]!.setValue('test_admin')
|
|
|
|
|
await inputs[1]!.setValue('password')
|
2026-08-01 12:17:33 +08:00
|
|
|
await wrapper.get('form').trigger('submit')
|
2026-07-30 17:59:43 +08:00
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(wrapper.get('[role="alert"]').text()).toBe('network unavailable')
|
|
|
|
|
expect(initialize).not.toHaveBeenCalled()
|
|
|
|
|
expect(replace).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
})
|