fix(auth): reset account state on logout (#662)

This commit is contained in:
InfinityPacer
2026-08-11 06:25:18 +08:00
committed by GitHub
parent e14b39c574
commit bf0e57a92f
6 changed files with 283 additions and 7 deletions

View File

@@ -0,0 +1,150 @@
import '@/router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface RouteLike {
fullPath: string
meta: Record<string, unknown>
name?: string
params?: Record<string, unknown>
path: string
}
type NavigationGuard = (to: RouteLike, from: RouteLike, next: ReturnType<typeof vi.fn>) => Promise<void>
type AfterEachHook = () => void
type Redirect = () => string
const routerMocks = vi.hoisted(() => ({
afterEach: undefined as AfterEachHook | undefined,
guard: undefined as NavigationGuard | undefined,
next: vi.fn(),
routes: [] as Array<{ path: string; redirect?: Redirect }>,
setRequestNavigatingState: vi.fn(),
}))
vi.mock('vue-router', () => ({
createRouter: (options: { routes: Array<{ path: string; redirect?: Redirect }> }) => {
routerMocks.routes = options.routes
return {
afterEach: (hook: AfterEachHook) => {
routerMocks.afterEach = hook
},
beforeEach: (guard: NavigationGuard) => {
routerMocks.guard = guard
},
}
},
createWebHashHistory: vi.fn(),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
}))
vi.mock('@/utils/requestOptimizer', () => ({
abortAllRequests: vi.fn(),
initializeRequestOptimizer: vi.fn(),
setNavigatingState: routerMocks.setRequestNavigatingState,
}))
function route(overrides: Partial<RouteLike> = {}): RouteLike {
return {
fullPath: '/apps',
meta: {},
path: '/apps',
...overrides,
}
}
async function runGuard(to: RouteLike) {
await routerMocks.guard?.(to, route({ fullPath: '/', path: '/' }), routerMocks.next)
}
describe('authentication route guard', () => {
beforeEach(() => {
setActivePinia(createPinia())
routerMocks.next.mockReset()
routerMocks.setRequestNavigatingState.mockReset()
})
it('routes the root according to the current session role', () => {
const rootRedirect = routerMocks.routes.find(item => item.path === '/' && item.redirect)?.redirect
expect(rootRedirect).toBeTypeOf('function')
expect(rootRedirect?.()).toBe('/login')
useAuthStore().setToken('token')
expect(rootRedirect?.()).toBe('/apps')
useUserStore().setSuperUser(true)
expect(rootRedirect?.()).toBe('/dashboard')
})
it('records an unauthenticated protected target before redirecting to login', async () => {
await runGuard(
route({
fullPath: '/subscribe/tv?tab=active',
meta: { requiresAuth: true },
path: '/subscribe/tv',
}),
)
expect(useAuthStore().originalPath).toBe('/subscribe/tv?tab=active')
expect(routerMocks.setRequestNavigatingState.mock.calls).toEqual([[true], [false]])
expect(routerMocks.next).toHaveBeenCalledOnce()
expect(routerMocks.next).toHaveBeenCalledWith('/login')
})
it('does not replace the saved business target while entering the login page', async () => {
useAuthStore().setOriginalPath('/resource?keyword=test')
await runGuard(route({ fullPath: '/login?lab=motion', path: '/login' }))
expect(useAuthStore().originalPath).toBe('/resource?keyword=test')
expect(routerMocks.next).toHaveBeenCalledWith()
})
it('allows ordinary protected routes and enforces declared permissions', async () => {
const authStore = useAuthStore()
const userStore = useUserStore()
authStore.setToken('token')
await runGuard(route({ meta: { requiresAuth: true } }))
expect(routerMocks.next).toHaveBeenLastCalledWith()
routerMocks.next.mockClear()
userStore.setPermissions({ discovery: true, features: { 'discovery.recommend': false } })
await runGuard(
route({
fullPath: '/recommend',
meta: { feature: 'discovery.recommend', permission: 'discovery', requiresAuth: true },
path: '/recommend',
}),
)
expect(routerMocks.next).toHaveBeenLastCalledWith('/apps')
expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false)
routerMocks.next.mockClear()
userStore.setSuperUser(true)
await runGuard(
route({
fullPath: '/recommend',
meta: { feature: 'discovery.recommend', permission: 'discovery', requiresAuth: true },
path: '/recommend',
}),
)
expect(routerMocks.next).toHaveBeenLastCalledWith()
})
it('clears the navigation state after a completed route', () => {
vi.useFakeTimers()
routerMocks.afterEach?.()
expect(routerMocks.setRequestNavigatingState).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(routerMocks.setRequestNavigatingState).toHaveBeenCalledWith(false)
})
})

View File

@@ -1,5 +1,6 @@
import { useAuthStore } from '@/stores/auth'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { useUserStore } from '@/stores/user'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
@@ -33,13 +34,23 @@ describe('auth store', () => {
expect(authStore.getToken).toBeNull()
})
it('logs out and clears plugin navigation state', () => {
it('logs out and clears all account-scoped state', () => {
const authStore = useAuthStore()
const pluginNavStore = usePluginSidebarNavStore()
const userStore = useUserStore()
const pendingRequest = Promise.resolve()
authStore.login({ token: 'test-token', remember: true })
authStore.setOriginalPath('/plugins')
userStore.loginUser({
avatar: '/avatar.png',
level: 3,
permissions: { admin: true, discovery: true },
superUser: true,
userID: 42,
userName: 'previous-user',
wizard: true,
})
pluginNavStore.$patch({
inflight: pendingRequest,
items: [
@@ -63,5 +74,14 @@ describe('auth store', () => {
expect(pluginNavStore.items).toEqual([])
expect(pluginNavStore.loaded).toBe(false)
expect(pluginNavStore.inflight).toBeNull()
expect(userStore.$state).toEqual({
avatar: '',
level: 1,
permissions: expect.objectContaining({ admin: false }),
superUser: false,
userID: -1,
userName: '',
wizard: false,
})
})
})

View File

@@ -0,0 +1,89 @@
import { useUserStore } from '@/stores/user'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
describe('user store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with an anonymous user and matching getters', () => {
const store = useUserStore()
expect(store.$state).toEqual({
avatar: '',
level: 1,
permissions: DEFAULT_PERMISSIONS,
superUser: false,
userID: -1,
userName: '',
wizard: false,
})
expect(store.getSuperUser).toBe(false)
expect(store.getUserID).toBe(-1)
expect(store.getUserName).toBe('')
expect(store.getAvatar).toBe('')
expect(store.getLevel).toBe(1)
expect(store.getPermissions).toEqual(DEFAULT_PERMISSIONS)
expect(store.getWizard).toBe(false)
})
it('stores a login response and fills omitted permission categories', () => {
const store = useUserStore()
store.loginUser({
avatar: '/avatar.png',
level: 2,
permissions: {
discovery: false,
features: { 'discovery.recommend': false },
},
superUser: false,
userID: 7,
userName: 'viewer',
wizard: true,
})
expect(store.$state).toEqual({
avatar: '/avatar.png',
level: 2,
permissions: {
...DEFAULT_PERMISSIONS,
discovery: false,
features: { 'discovery.recommend': false },
},
superUser: false,
userID: 7,
userName: 'viewer',
wizard: true,
})
})
it('creates isolated default permissions for each store instance', () => {
const firstStore = useUserStore()
setActivePinia(createPinia())
expect(useUserStore().permissions.features).not.toBe(firstStore.permissions.features)
})
it('resets every account field without sharing permission mutations', () => {
const firstStore = useUserStore()
firstStore.setPermissions({ admin: true })
firstStore.permissions.features!['discovery.recommend'] = false
firstStore.reset()
expect(firstStore.$state).toEqual({
avatar: '',
level: 1,
permissions: DEFAULT_PERMISSIONS,
superUser: false,
userID: -1,
userName: '',
wizard: false,
})
setActivePinia(createPinia())
expect(useUserStore().permissions.features).toEqual({})
})
})

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import type { authState } from '@/stores/types'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { useUserStore } from '@/stores/user'
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
export const useAuthStore = defineStore('auth', {
@@ -33,6 +34,8 @@ export const useAuthStore = defineStore('auth', {
logout() {
this.clearToken()
this.setOriginalPath(null)
// 身份和权限属于登录会话;退出后不得被同一浏览器中的下一个账号继承。
useUserStore().reset()
clearCachedMediaSubscribeStatuses()
usePluginSidebarNavStore().reset()
},

View File

@@ -9,7 +9,10 @@ export const useUserStore = defineStore('user', {
userName: '',
avatar: '',
level: 1,
permissions: DEFAULT_PERMISSIONS,
permissions: {
...DEFAULT_PERMISSIONS,
features: { ...DEFAULT_PERMISSIONS.features },
},
wizard: false,
}),
@@ -33,7 +36,11 @@ export const useUserStore = defineStore('user', {
this.level = level
},
setPermissions(permissions: object) {
this.permissions = { ...DEFAULT_PERMISSIONS, ...permissions }
const mergedPermissions = { ...DEFAULT_PERMISSIONS, ...permissions }
this.permissions = {
...mergedPermissions,
features: { ...mergedPermissions.features },
}
},
setWizard(wizard: boolean) {
this.wizard = wizard

View File

@@ -312,6 +312,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/utils/federationLoader.ts',
'src/utils/federationRuntime.ts',
'src/stores/auth.ts',
'src/stores/user.ts',
'src/stores/pluginSidebarNav.ts',
'src/pages/appcenter.vue',
'src/pages/recommend.vue',
@@ -628,10 +629,16 @@ export default defineConfig(({ command, mode, isPreview }) => ({
statements: 80,
},
'src/stores/auth.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/stores/user.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/stores/pluginSidebarNav.ts': {
branches: 85,