mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +08:00
fix(auth): reset account state on logout (#662)
This commit is contained in:
150
src/router/__tests__/auth-guard.spec.ts
Normal file
150
src/router/__tests__/auth-guard.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
89
src/stores/__tests__/user.spec.ts
Normal file
89
src/stores/__tests__/user.spec.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user