mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix(auth): reset account state on logout (#662)
This commit is contained in:
@@ -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 { useAuthStore } from '@/stores/auth'
|
||||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
@@ -33,13 +34,23 @@ describe('auth store', () => {
|
|||||||
expect(authStore.getToken).toBeNull()
|
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 authStore = useAuthStore()
|
||||||
const pluginNavStore = usePluginSidebarNavStore()
|
const pluginNavStore = usePluginSidebarNavStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
const pendingRequest = Promise.resolve()
|
const pendingRequest = Promise.resolve()
|
||||||
|
|
||||||
authStore.login({ token: 'test-token', remember: true })
|
authStore.login({ token: 'test-token', remember: true })
|
||||||
authStore.setOriginalPath('/plugins')
|
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({
|
pluginNavStore.$patch({
|
||||||
inflight: pendingRequest,
|
inflight: pendingRequest,
|
||||||
items: [
|
items: [
|
||||||
@@ -63,5 +74,14 @@ describe('auth store', () => {
|
|||||||
expect(pluginNavStore.items).toEqual([])
|
expect(pluginNavStore.items).toEqual([])
|
||||||
expect(pluginNavStore.loaded).toBe(false)
|
expect(pluginNavStore.loaded).toBe(false)
|
||||||
expect(pluginNavStore.inflight).toBeNull()
|
expect(pluginNavStore.inflight).toBeNull()
|
||||||
|
expect(userStore.$state).toEqual({
|
||||||
|
avatar: '',
|
||||||
|
level: 1,
|
||||||
|
permissions: expect.objectContaining({ admin: false }),
|
||||||
|
superUser: false,
|
||||||
|
userID: -1,
|
||||||
|
userName: '',
|
||||||
|
wizard: false,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { defineStore } from 'pinia'
|
||||||
import type { authState } from '@/stores/types'
|
import type { authState } from '@/stores/types'
|
||||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
|
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
@@ -33,6 +34,8 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
logout() {
|
logout() {
|
||||||
this.clearToken()
|
this.clearToken()
|
||||||
this.setOriginalPath(null)
|
this.setOriginalPath(null)
|
||||||
|
// 身份和权限属于登录会话;退出后不得被同一浏览器中的下一个账号继承。
|
||||||
|
useUserStore().reset()
|
||||||
clearCachedMediaSubscribeStatuses()
|
clearCachedMediaSubscribeStatuses()
|
||||||
usePluginSidebarNavStore().reset()
|
usePluginSidebarNavStore().reset()
|
||||||
},
|
},
|
||||||
|
|||||||
+9
-2
@@ -9,7 +9,10 @@ export const useUserStore = defineStore('user', {
|
|||||||
userName: '',
|
userName: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
level: 1,
|
level: 1,
|
||||||
permissions: DEFAULT_PERMISSIONS,
|
permissions: {
|
||||||
|
...DEFAULT_PERMISSIONS,
|
||||||
|
features: { ...DEFAULT_PERMISSIONS.features },
|
||||||
|
},
|
||||||
wizard: false,
|
wizard: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -33,7 +36,11 @@ export const useUserStore = defineStore('user', {
|
|||||||
this.level = level
|
this.level = level
|
||||||
},
|
},
|
||||||
setPermissions(permissions: object) {
|
setPermissions(permissions: object) {
|
||||||
this.permissions = { ...DEFAULT_PERMISSIONS, ...permissions }
|
const mergedPermissions = { ...DEFAULT_PERMISSIONS, ...permissions }
|
||||||
|
this.permissions = {
|
||||||
|
...mergedPermissions,
|
||||||
|
features: { ...mergedPermissions.features },
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setWizard(wizard: boolean) {
|
setWizard(wizard: boolean) {
|
||||||
this.wizard = wizard
|
this.wizard = wizard
|
||||||
|
|||||||
+11
-4
@@ -312,6 +312,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/utils/federationLoader.ts',
|
'src/utils/federationLoader.ts',
|
||||||
'src/utils/federationRuntime.ts',
|
'src/utils/federationRuntime.ts',
|
||||||
'src/stores/auth.ts',
|
'src/stores/auth.ts',
|
||||||
|
'src/stores/user.ts',
|
||||||
'src/stores/pluginSidebarNav.ts',
|
'src/stores/pluginSidebarNav.ts',
|
||||||
'src/pages/appcenter.vue',
|
'src/pages/appcenter.vue',
|
||||||
'src/pages/recommend.vue',
|
'src/pages/recommend.vue',
|
||||||
@@ -628,10 +629,16 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
'src/stores/auth.ts': {
|
'src/stores/auth.ts': {
|
||||||
branches: 75,
|
branches: 85,
|
||||||
functions: 80,
|
functions: 90,
|
||||||
lines: 80,
|
lines: 90,
|
||||||
statements: 80,
|
statements: 90,
|
||||||
|
},
|
||||||
|
'src/stores/user.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
},
|
},
|
||||||
'src/stores/pluginSidebarNav.ts': {
|
'src/stores/pluginSidebarNav.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
|
|||||||
Reference in New Issue
Block a user