3973abea8b
- 自建轻量 axios client(src/api/client.ts):Bearer 注入、401 清会话跳登录
(login 请求除外)、409 精确匹配"必须先修改初始密码"跳强制改密(区别于并发
冲突 409)、429/网络错误统一 ElMessage、ApiError 标准化、showErrorMessage
可opt-out——模板 utils/http 的 {code,msg,data} 信封与后端裸 HTTP 语义不符故弃用
- Pinia auth store(src/store/modules/auth.ts):token/role/tenant_slug/
must_change 落地并持久化(all-kefu-auth key),login/logout/changePassword/
会话过期/强制改密跳转(动态 import 断循环)
- 路由守卫:token 事实源判定、must_change 全站门(除改密页)、UserInfo 由登录
快照派生(后端无用户信息端点);user store 残留自愈
- 登录页实装(去除角色调试开关)、强制改密页实装;用户菜单/锁屏对接真实 logout
- 域 API 骨架:tenants/config/conversations/stats/settings(类型+端点常量)
- mask 哨兵助手:__SET__ ↔ 已设置 表单语义
- vitest 25 例(client 11/store 8/mask 6);登录页拖拽验证 reset 空引用修复
Co-Authored-By: Claude Code <noreply@anthropic.com>
212 lines
7.2 KiB
TypeScript
212 lines
7.2 KiB
TypeScript
/**
|
||
* auth store 单元测试(Phase 3 Task 3)
|
||
*
|
||
* 覆盖:
|
||
* - login 成功存 token/role/tenant_slug/must_change_password/username(含模板侧同步)
|
||
* - logout:调 API + 清空状态 + 模板侧 logOut(API 失败也照常本地清)
|
||
* - localStorage 持久化(all-kefu 前缀键;新建 pinia 实例模拟刷新恢复)
|
||
* - handleSessionExpired / handleMustChangePassword / changePassword
|
||
*/
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import { createApp, defineComponent } from 'vue'
|
||
import { createPersistedState } from 'pinia-plugin-persistedstate'
|
||
|
||
const mocks = vi.hoisted(() => ({
|
||
fetchLogin: vi.fn(),
|
||
fetchLogout: vi.fn(),
|
||
fetchChangePassword: vi.fn(),
|
||
userStore: {
|
||
logOut: vi.fn(),
|
||
setLoginStatus: vi.fn(),
|
||
setToken: vi.fn(),
|
||
setUserInfo: vi.fn(),
|
||
checkAndClearWorktabs: vi.fn(),
|
||
info: {}
|
||
},
|
||
routerPush: vi.fn(),
|
||
routerReplace: vi.fn(),
|
||
currentRoute: { value: { path: '/' } }
|
||
}))
|
||
|
||
vi.mock('@/api/auth', () => ({
|
||
fetchLogin: mocks.fetchLogin,
|
||
fetchLogout: mocks.fetchLogout,
|
||
fetchChangePassword: mocks.fetchChangePassword
|
||
}))
|
||
|
||
vi.mock('@/store/modules/user', () => ({
|
||
useUserStore: () => mocks.userStore
|
||
}))
|
||
|
||
vi.mock('@/router', () => ({
|
||
router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute }
|
||
}))
|
||
|
||
import { useAuthStore } from '@/store/modules/auth'
|
||
|
||
const LOGIN_RESPONSE = {
|
||
token: 'tok-abc',
|
||
role: 'super',
|
||
tenant_slug: null,
|
||
must_change_password: true
|
||
} as const
|
||
|
||
/**
|
||
* 等价应用的持久化 pinia:
|
||
* pinia.use() 在未安装 app 时(_a 为空)插件只进 toBeInstalled 队列,
|
||
* 必须 app.use(pinia) 才真正生效——真实应用经 initStore(main.ts)如此;
|
||
* 测试里用一次性 app 复刻同一装配,否则持久化插件静默不运行。
|
||
*/
|
||
function freshPersistedPinia() {
|
||
const pinia = createPinia()
|
||
pinia.use(createPersistedState())
|
||
createApp(defineComponent(() => () => null)).use(pinia)
|
||
setActivePinia(pinia)
|
||
return pinia
|
||
}
|
||
|
||
describe('auth store(src/store/modules/auth.ts)', () => {
|
||
beforeEach(() => {
|
||
localStorage.clear()
|
||
vi.clearAllMocks()
|
||
mocks.currentRoute.value.path = '/'
|
||
})
|
||
|
||
it('login 成功:存 token/role/tenantSlug/mustChangePassword/username 并同步模板侧', async () => {
|
||
freshPersistedPinia()
|
||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||
|
||
const store = useAuthStore()
|
||
await store.login({ username: 'admin', password: 'admin123' })
|
||
|
||
expect(store.token).toBe('tok-abc')
|
||
expect(store.role).toBe('super')
|
||
expect(store.tenantSlug).toBeNull()
|
||
expect(store.mustChangePassword).toBe(true)
|
||
expect(store.username).toBe('admin')
|
||
expect(store.isAuthenticated).toBe(true)
|
||
|
||
// 模板侧(user store)同步:token / 登录标记 / 用户信息(菜单过滤用 roles)
|
||
expect(mocks.userStore.setToken).toHaveBeenCalledWith('tok-abc', '')
|
||
expect(mocks.userStore.setLoginStatus).toHaveBeenCalledWith(true)
|
||
expect(mocks.userStore.setUserInfo).toHaveBeenCalled()
|
||
const info = mocks.userStore.setUserInfo.mock.calls[0][0]
|
||
expect(info.roles).toEqual(['super'])
|
||
expect(info.userName).toBe('admin')
|
||
})
|
||
|
||
it('login 失败:状态保持空、错误向上抛', async () => {
|
||
freshPersistedPinia()
|
||
mocks.fetchLogin.mockRejectedValue(new Error('用户名或密码错误'))
|
||
|
||
const store = useAuthStore()
|
||
await expect(store.login({ username: 'a', password: 'b' })).rejects.toThrow('用户名或密码错误')
|
||
|
||
expect(store.token).toBe('')
|
||
expect(store.isAuthenticated).toBe(false)
|
||
expect(mocks.userStore.setLoginStatus).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('logout:调登出 API + 清空状态 + 模板侧 logOut', async () => {
|
||
freshPersistedPinia()
|
||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||
mocks.fetchLogout.mockResolvedValue({ ok: true })
|
||
|
||
const store = useAuthStore()
|
||
await store.login({ username: 'admin', password: 'x' })
|
||
|
||
await store.logout()
|
||
|
||
expect(mocks.fetchLogout).toHaveBeenCalledTimes(1)
|
||
expect(store.token).toBe('')
|
||
expect(store.role).toBe('')
|
||
expect(store.tenantSlug).toBeNull()
|
||
expect(store.username).toBe('')
|
||
expect(store.mustChangePassword).toBe(false)
|
||
expect(store.isAuthenticated).toBe(false)
|
||
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('logout 时后端已不可达:仍完成本地清理(best effort)', async () => {
|
||
freshPersistedPinia()
|
||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||
mocks.fetchLogout.mockRejectedValue(new Error('network down'))
|
||
|
||
const store = useAuthStore()
|
||
await store.login({ username: 'admin', password: 'x' })
|
||
|
||
await expect(store.logout()).resolves.toBeUndefined()
|
||
|
||
expect(store.token).toBe('')
|
||
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('localStorage 持久化:all-kefu 前缀键;新 pinia 实例(模拟刷新)恢复会话', async () => {
|
||
freshPersistedPinia()
|
||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE, role: 'tenant', tenant_slug: 'jiancai', must_change_password: false })
|
||
|
||
await useAuthStore().login({ username: 'jcadmin', password: 'x' })
|
||
|
||
// 持久化键带 all-kefu 前缀
|
||
expect(localStorage.getItem('all-kefu-auth')).toBeTruthy()
|
||
|
||
// 模拟页面刷新:全新 pinia + 插件,从 localStorage 恢复
|
||
freshPersistedPinia()
|
||
const restored = useAuthStore()
|
||
expect(restored.token).toBe('tok-abc')
|
||
expect(restored.role).toBe('tenant')
|
||
expect(restored.tenantSlug).toBe('jiancai')
|
||
expect(restored.username).toBe('jcadmin')
|
||
expect(restored.mustChangePassword).toBe(false)
|
||
expect(restored.isAuthenticated).toBe(true)
|
||
})
|
||
|
||
it('handleSessionExpired:清空会话并委托模板侧 logOut(401 全局处理路径)', () => {
|
||
freshPersistedPinia()
|
||
const store = useAuthStore()
|
||
store.token = 'tok'
|
||
store.role = 'super'
|
||
store.username = 'admin'
|
||
|
||
store.handleSessionExpired()
|
||
|
||
expect(store.token).toBe('')
|
||
expect(store.isAuthenticated).toBe(false)
|
||
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('handleMustChangePassword:置标志并跳强制改密页;已在改密页不重复跳', async () => {
|
||
freshPersistedPinia()
|
||
const store = useAuthStore()
|
||
|
||
await store.handleMustChangePassword()
|
||
|
||
expect(store.mustChangePassword).toBe(true)
|
||
await vi.waitFor(() => {
|
||
expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password')
|
||
})
|
||
|
||
// 已在改密页:不再跳
|
||
mocks.routerReplace.mockClear()
|
||
mocks.currentRoute.value.path = '/auth/force-change-password'
|
||
await store.handleMustChangePassword()
|
||
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('changePassword 成功:清 must_change 标志', async () => {
|
||
freshPersistedPinia()
|
||
mocks.fetchChangePassword.mockResolvedValue({ ok: true, must_change_password: false })
|
||
const store = useAuthStore()
|
||
store.mustChangePassword = true
|
||
|
||
await store.changePassword({ old_password: 'old', new_password: 'new-pass-123' })
|
||
|
||
expect(mocks.fetchChangePassword).toHaveBeenCalledWith({
|
||
old_password: 'old',
|
||
new_password: 'new-pass-123'
|
||
})
|
||
expect(store.mustChangePassword).toBe(false)
|
||
})
|
||
})
|