diff --git a/frontend/src/stores/__tests__/user.spec.ts b/frontend/src/stores/__tests__/user.spec.ts index ae9758c..cb3a475 100644 --- a/frontend/src/stores/__tests__/user.spec.ts +++ b/frontend/src/stores/__tests__/user.spec.ts @@ -11,6 +11,8 @@ vi.mock('@/utils/request', () => ({ }, })) +import { fakeJwtWithRole, VALID_TOKEN, VALID_REFRESH_TOKEN } from '@/test/helpers' + describe('useUserStore', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -30,17 +32,17 @@ describe('useUserStore', () => { }) it('restores token from localStorage', () => { - localStorage.setItem('access_token', 'header.payload.signature') - localStorage.setItem('refresh_token', 'rh.rp.rs') + localStorage.setItem('access_token', VALID_TOKEN) + localStorage.setItem('refresh_token', VALID_REFRESH_TOKEN) const store = useUserStore() - expect(store.token).toBe('header.payload.signature') - expect(store.refreshToken).toBe('rh.rp.rs') + expect(store.token).toBe(VALID_TOKEN) + expect(store.refreshToken).toBe(VALID_REFRESH_TOKEN) expect(store.isLoggedIn).toBe(true) }) - it('restores user info from localStorage', () => { + it('restores user info from localStorage (but isAdmin depends on JWT)', () => { const savedUser = { id: 1, username: 'admin', email: 'a@b.com', role: 'administrator', status: 1 } localStorage.setItem('user', JSON.stringify(savedUser)) @@ -48,7 +50,8 @@ describe('useUserStore', () => { expect(store.user).toEqual(savedUser) expect(store.username).toBe('admin') - expect(store.isAdmin).toBe(true) + // isAdmin is false because there's no JWT token set + expect(store.isAdmin).toBe(false) }) it('handles corrupted user data in localStorage', () => { @@ -64,12 +67,12 @@ describe('useUserStore', () => { it('sets tokens in store and localStorage', () => { const store = useUserStore() - store.setToken('header.payload.signature', 'rh.rp.rs') + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN) - expect(store.token).toBe('header.payload.signature') - expect(store.refreshToken).toBe('rh.rp.rs') - expect(localStorage.getItem('access_token')).toBe('header.payload.signature') - expect(localStorage.getItem('refresh_token')).toBe('rh.rp.rs') + expect(store.token).toBe(VALID_TOKEN) + expect(store.refreshToken).toBe(VALID_REFRESH_TOKEN) + expect(localStorage.getItem('access_token')).toBe(VALID_TOKEN) + expect(localStorage.getItem('refresh_token')).toBe(VALID_REFRESH_TOKEN) expect(store.isLoggedIn).toBe(true) }) }) @@ -78,10 +81,10 @@ describe('useUserStore', () => { it('does not persist tokens to localStorage', () => { const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', false) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, false) - expect(store.token).toBe('h.p.s') - expect(store.refreshToken).toBe('r.p.s') + expect(store.token).toBe(VALID_TOKEN) + expect(store.refreshToken).toBe(VALID_REFRESH_TOKEN) expect(localStorage.getItem('access_token')).toBeNull() expect(localStorage.getItem('refresh_token')).toBeNull() }) @@ -90,7 +93,7 @@ describe('useUserStore', () => { localStorage.setItem('user', JSON.stringify({ id: 1, username: 'old' })) const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', false) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, false) expect(localStorage.getItem('user')).toBeNull() }) @@ -99,7 +102,7 @@ describe('useUserStore', () => { describe('setUser', () => { it('persists to localStorage when remember=true', () => { const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', true) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, true) const userInfo = { id: 1, username: 'test', email: 't@t.com', role: 'user', status: 1 } store.setUser(userInfo) @@ -122,7 +125,7 @@ describe('useUserStore', () => { it('does not write to localStorage after setToken(remember=false)', () => { const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', false) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, false) const userInfo = { id: 1, username: 'test', email: 't@t.com', role: 'user', status: 1 } store.setUser(userInfo) @@ -135,7 +138,7 @@ describe('useUserStore', () => { describe('logout', () => { it('clears all state and localStorage', () => { const store = useUserStore() - store.setToken('h.p.s', 'r.p.s') + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN) store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 }) store.logout() @@ -151,7 +154,7 @@ describe('useUserStore', () => { it('resets _remember so subsequent setUser does not persist', () => { const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', true) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, true) store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 }) expect(localStorage.getItem('user')).not.toBeNull() @@ -170,11 +173,14 @@ describe('useUserStore', () => { vi.mocked(api.get).mockResolvedValueOnce(mockUser) const store = useUserStore() + // Set a JWT token with admin role so isAdmin works + store.setToken(fakeJwtWithRole('administrator'), 'rt') const result = await store.fetchCurrentUser() expect(api.get).toHaveBeenCalledWith('/api/v1/me') expect(result).toEqual(mockUser) expect(store.user).toEqual(mockUser) + // isAdmin is now derived from JWT, not user.role expect(store.isAdmin).toBe(true) }) }) @@ -186,7 +192,7 @@ describe('useUserStore', () => { vi.mocked(api.put).mockResolvedValueOnce(updatedUser) const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', true) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, true) const result = await store.updateProfile({ email: 'new@e.com' }) expect(api.put).toHaveBeenCalledWith('/api/v1/me/profile', { email: 'new@e.com' }) @@ -214,7 +220,7 @@ describe('useUserStore', () => { vi.mocked(api.put).mockResolvedValueOnce(undefined) const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', true) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, true) store.setUser({ id: 1, username: 'u', email: 'u@e.com', role: 'user', status: 1 }) await store.changePassword({ @@ -238,7 +244,7 @@ describe('useUserStore', () => { vi.mocked(api.put).mockRejectedValueOnce(new Error('旧密码错误')) const store = useUserStore() - store.setToken('h.p.s', 'r.p.s', true) + store.setToken(VALID_TOKEN, VALID_REFRESH_TOKEN, true) store.setUser({ id: 1, username: 'u', email: 'u@e.com', role: 'user', status: 1 }) await expect(store.changePassword({ @@ -247,23 +253,58 @@ describe('useUserStore', () => { new_password_confirmation: 'new123', })).rejects.toThrow('旧密码错误') - expect(store.token).toBe('h.p.s') + expect(store.token).toBe(VALID_TOKEN) expect(store.user).not.toBeNull() }) }) - describe('computed properties', () => { - it('isAdmin returns true for admin role', () => { + describe('JWT-based role and isAdmin', () => { + it('role computed derives from JWT token', () => { const store = useUserStore() - store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 }) + store.setToken(fakeJwtWithRole('administrator'), VALID_REFRESH_TOKEN) + expect(store.isAdmin).toBe(true) + + store.setToken(fakeJwtWithRole('accessor'), VALID_REFRESH_TOKEN) + expect(store.isAdmin).toBe(false) + }) + + it('isAdmin is false when token is null', () => { + const store = useUserStore() + expect(store.isAdmin).toBe(false) + }) + + it('localStorage user role tampering does not affect isAdmin', () => { + // Simulate attacker: localStorage user says administrator, but JWT says accessor + const savedUser = { id: 1, username: 'hacker', email: 'h@h.com', role: 'administrator', status: 1 } + localStorage.setItem('user', JSON.stringify(savedUser)) + localStorage.setItem('access_token', fakeJwtWithRole('accessor')) + localStorage.setItem('refresh_token', 'rt') + + const store = useUserStore() + + // user.role says administrator (from localStorage) + expect(store.user?.role).toBe('administrator') + // but isAdmin is false (from JWT which says accessor) + expect(store.isAdmin).toBe(false) + }) + + it('setToken updates role and isAdmin reactively', () => { + const store = useUserStore() + + store.setToken(fakeJwtWithRole('accessor'), 'rt') + expect(store.isAdmin).toBe(false) + + store.setToken(fakeJwtWithRole('administrator'), 'rt') expect(store.isAdmin).toBe(true) }) - it('isAdmin returns false for non-admin role', () => { + it('logout resets role to null and isAdmin to false', () => { const store = useUserStore() - store.setUser({ id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }) + store.setToken(fakeJwtWithRole('administrator'), 'rt') + expect(store.isAdmin).toBe(true) + store.logout() expect(store.isAdmin).toBe(false) }) }) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index a48844f..74a95e3 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -1,4 +1,5 @@ import { api } from '@/utils/request' +import { decodeJwtPayload, extractRoleFromJwt } from '@/utils/jwt' export interface UserInfo { id: number @@ -16,12 +17,17 @@ export const useUserStore = defineStore('user', () => { // 默认不持久化;从 localStorage 推断:有 token 说明上次选了"记住我" const _remember = ref(!!localStorage.getItem('access_token')) - // 基本 JWT 格式校验(三段式),防止垃圾值绕过路由守卫 + // JWT 格式校验:三段式 + payload 可解码为有效 JSON,防止垃圾值绕过路由守卫 const isLoggedIn = computed(() => { const t = token.value - return !!t && t.split('.').length === 3 + return !!t && decodeJwtPayload(t) !== null }) - const isAdmin = computed(() => user.value?.role === 'administrator') + // role 从 JWT token 响应式派生,唯一可信数据源(不依赖 localStorage user JSON) + const role = computed(() => { + if (!token.value) return null + return extractRoleFromJwt(token.value) + }) + const isAdmin = computed(() => role.value === 'administrator') const username = computed(() => user.value?.username || '') function hasPermission(action: 'read' | 'write'): boolean { @@ -97,6 +103,7 @@ export const useUserStore = defineStore('user', () => { refreshToken, user, isLoggedIn, + role, isAdmin, username, setToken,