/** * auth store 单元测试 * * 覆盖:初始状态、登录/登出流程、权限提取、localStorage 持久化、状态重置。 * 所有外部依赖(API 调用、localStorage)均被 mock。 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // --------------------------------------------------------------------------- // Mock 外部依赖(必须在 import 被测模块之前) // --------------------------------------------------------------------------- // Mock localStorage — auth.ts 模块顶层直接读取 localStorage, // 必须在 vi.mock 工厂中提供实现,否则 atob/jwt 解码会失败。 const localStorageStore: Record = {}; vi.stubGlobal('localStorage', { getItem: vi.fn((key: string) => localStorageStore[key] ?? null), setItem: vi.fn((key: string, value: string) => { localStorageStore[key] = value; }), removeItem: vi.fn((key: string) => { delete localStorageStore[key]; }), clear: vi.fn(() => { Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]); }), get length() { return Object.keys(localStorageStore).length; }, key: vi.fn((index: number) => Object.keys(localStorageStore)[index] ?? null), }); // Mock auth API const mockApiLogin = vi.fn(); const mockApiLogout = vi.fn(); vi.mock('../api/auth', () => ({ login: (...args: unknown[]) => mockApiLogin(...args), logout: (...args: unknown[]) => mockApiLogout(...args), })); // Mock clearApiCache const mockClearApiCache = vi.fn(); vi.mock('../api/client', () => ({ clearApiCache: (...args: unknown[]) => mockClearApiCache(...args), })); // --------------------------------------------------------------------------- // 在 mock 生效后导入被测模块 // --------------------------------------------------------------------------- import { useAuthStore } from './auth'; import type { UserInfo, LoginResponse } from '../api/auth'; // --------------------------------------------------------------------------- // 测试辅助函数 // --------------------------------------------------------------------------- /** 生成一个包含 permissions 数组的 JWT payload 并编码为合法 token */ function createFakeToken(permissions: string[], extra: Record = {}): string { const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const payload = btoa(JSON.stringify({ permissions, ...extra })) .replace(/\+/g, '-') .replace(/\//g, '_'); const signature = btoa('fake-signature'); return `${header}.${payload}.${signature}`; } function createFakeUser(overrides: Partial = {}): UserInfo { return { id: 'user-001', username: 'testuser', email: 'test@example.com', phone: '13800138000', display_name: '测试用户', avatar_url: undefined, status: 'active', roles: [ { id: 'role-001', name: '管理员', code: 'admin', is_system: true }, ], version: 1, ...overrides, }; } function createFakeLoginResponse(overrides: Partial = {}): LoginResponse { return { access_token: createFakeToken(['diary.journal.read', 'diary.class.manage']), refresh_token: 'refresh-token-xxx', expires_in: 3600, user: createFakeUser(), ...overrides, }; } // --------------------------------------------------------------------------- // 测试套件 // --------------------------------------------------------------------------- describe('useAuthStore', () => { beforeEach(() => { // 重置所有 mock 调用记录 vi.clearAllMocks(); // 清空 localStorage 存储和 store 状态 Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]); // 将 store 状态重置为未认证 useAuthStore.setState({ user: null, isAuthenticated: false, loading: false, permissions: [], }); }); afterEach(() => { vi.restoreAllMocks(); }); // ========================================================================= // 初始状态 // ========================================================================= describe('初始状态', () => { it('应有正确的默认状态字段', () => { const state = useAuthStore.getState(); expect(state).toHaveProperty('user'); expect(state).toHaveProperty('isAuthenticated'); expect(state).toHaveProperty('loading'); expect(state).toHaveProperty('permissions'); expect(state).toHaveProperty('login'); expect(state).toHaveProperty('logout'); expect(state).toHaveProperty('loadFromStorage'); }); it('无 token 时应为未认证状态', () => { const state = useAuthStore.getState(); expect(state.isAuthenticated).toBe(false); expect(state.user).toBeNull(); expect(state.permissions).toEqual([]); expect(state.loading).toBe(false); }); }); // ========================================================================= // 登录流程 // ========================================================================= describe('login', () => { it('成功登录应更新 user、isAuthenticated、permissions 并写入 localStorage', async () => { const fakeUser = createFakeUser(); const fakeResponse = createFakeLoginResponse({ user: fakeUser }); mockApiLogin.mockResolvedValue(fakeResponse); await useAuthStore.getState().login('testuser', 'password123'); const state = useAuthStore.getState(); // store 状态正确 expect(state.user).toEqual(fakeUser); expect(state.isAuthenticated).toBe(true); expect(state.loading).toBe(false); expect(state.permissions).toEqual(['diary.journal.read', 'diary.class.manage']); // API 被正确调用 expect(mockApiLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' }); // localStorage 被写入 expect(localStorage.setItem).toHaveBeenCalledWith('access_token', fakeResponse.access_token); expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', fakeResponse.refresh_token); expect(localStorage.setItem).toHaveBeenCalledWith('user', JSON.stringify(fakeUser)); // API 缓存被清除 expect(mockClearApiCache).toHaveBeenCalled(); }); it('登录中 loading 应为 true,完成后恢复为 false', async () => { let resolveLogin: (value: unknown) => void; const loginPromise = new Promise((resolve) => { resolveLogin = resolve; }); mockApiLogin.mockReturnValue(loginPromise); const loginAction = useAuthStore.getState().login('user', 'pass'); // 登录进行中 expect(useAuthStore.getState().loading).toBe(true); resolveLogin!(createFakeLoginResponse()); await loginAction; expect(useAuthStore.getState().loading).toBe(false); }); it('登录失败应重置 loading 为 false 并向上抛出错误', async () => { const error = new Error('Invalid credentials'); mockApiLogin.mockRejectedValue(error); await expect( useAuthStore.getState().login('baduser', 'wrongpass'), ).rejects.toThrow('Invalid credentials'); const state = useAuthStore.getState(); expect(state.loading).toBe(false); expect(state.isAuthenticated).toBe(false); expect(state.user).toBeNull(); // 失败不应写入 localStorage expect(localStorage.setItem).not.toHaveBeenCalled(); }); it('登录失败不应清除 API 缓存', async () => { mockApiLogin.mockRejectedValue(new Error('Network error')); await expect( useAuthStore.getState().login('user', 'pass'), ).rejects.toThrow(); expect(mockClearApiCache).not.toHaveBeenCalled(); }); }); // ========================================================================= // 登出流程 // ========================================================================= describe('logout', () => { it('登出应清除所有状态和 localStorage', async () => { // 先登录 const fakeUser = createFakeUser(); const fakeResponse = createFakeLoginResponse({ user: fakeUser }); mockApiLogin.mockResolvedValue(fakeResponse); await useAuthStore.getState().login('testuser', 'password'); // 确认已登录 expect(useAuthStore.getState().isAuthenticated).toBe(true); // 登出 mockApiLogout.mockResolvedValue(undefined); await useAuthStore.getState().logout(); const state = useAuthStore.getState(); expect(state.user).toBeNull(); expect(state.isAuthenticated).toBe(false); expect(state.permissions).toEqual([]); // localStorage 被清除 expect(localStorage.removeItem).toHaveBeenCalledWith('access_token'); expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token'); expect(localStorage.removeItem).toHaveBeenCalledWith('user'); // API 缓存被清除 expect(mockClearApiCache).toHaveBeenCalled(); }); it('登出 API 报错时仍应清除本地状态(不抛出错误)', async () => { // 先设置一个已认证状态 useAuthStore.setState({ user: createFakeUser(), isAuthenticated: true, permissions: ['diary.journal.read'], }); localStorageStore['access_token'] = 'some-token'; // logout API 失败 mockApiLogout.mockRejectedValue(new Error('Server unreachable')); // 不应抛出 await expect(useAuthStore.getState().logout()).resolves.toBeUndefined(); const state = useAuthStore.getState(); expect(state.user).toBeNull(); expect(state.isAuthenticated).toBe(false); expect(state.permissions).toEqual([]); expect(localStorage.removeItem).toHaveBeenCalledWith('access_token'); }); it('登出时应调用 logout API', async () => { mockApiLogout.mockResolvedValue(undefined); await useAuthStore.getState().logout(); expect(mockApiLogout).toHaveBeenCalledTimes(1); }); }); // ========================================================================= // 权限提取(extractPermissions 内部逻辑) // ========================================================================= describe('权限提取', () => { it('登录后 permissions 应从 JWT token 中正确解析', async () => { const permissions = ['diary.journal.read', 'diary.class.manage', 'diary.topic.assign']; const token = createFakeToken(permissions); const fakeResponse = createFakeLoginResponse({ access_token: token }); mockApiLogin.mockResolvedValue(fakeResponse); await useAuthStore.getState().login('user', 'pass'); expect(useAuthStore.getState().permissions).toEqual(permissions); }); it('token 无 permissions 字段时 permissions 应为空数组', async () => { // 手动构造一个没有 permissions 的 token const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const payload = btoa(JSON.stringify({ sub: 'user-001' })) .replace(/\+/g, '-') .replace(/\//g, '_'); const signature = btoa('sig'); const token = `${header}.${payload}.${signature}`; const fakeResponse = createFakeLoginResponse({ access_token: token }); mockApiLogin.mockResolvedValue(fakeResponse); await useAuthStore.getState().login('user', 'pass'); expect(useAuthStore.getState().permissions).toEqual([]); }); it('token 格式非法时 permissions 应为空数组', async () => { const fakeResponse = createFakeLoginResponse({ access_token: 'not-a-jwt' }); mockApiLogin.mockResolvedValue(fakeResponse); await useAuthStore.getState().login('user', 'pass'); expect(useAuthStore.getState().permissions).toEqual([]); }); it('空 permissions 数组应正确解析', async () => { const token = createFakeToken([]); const fakeResponse = createFakeLoginResponse({ access_token: token }); mockApiLogin.mockResolvedValue(fakeResponse); await useAuthStore.getState().login('user', 'pass'); expect(useAuthStore.getState().permissions).toEqual([]); }); }); // ========================================================================= // localStorage 持久化 / loadFromStorage // ========================================================================= describe('loadFromStorage', () => { it('localStorage 有有效 token 和 user 时应恢复认证状态', () => { const fakeUser = createFakeUser(); const permissions = ['diary.journal.read']; const token = createFakeToken(permissions); localStorageStore['access_token'] = token; localStorageStore['user'] = JSON.stringify(fakeUser); useAuthStore.getState().loadFromStorage(); const state = useAuthStore.getState(); expect(state.user).toEqual(fakeUser); expect(state.isAuthenticated).toBe(true); expect(state.permissions).toEqual(permissions); }); it('localStorage 无 token 时应保持未认证', () => { localStorageStore['user'] = JSON.stringify(createFakeUser()); // 不设置 access_token useAuthStore.getState().loadFromStorage(); const state = useAuthStore.getState(); expect(state.isAuthenticated).toBe(false); expect(state.user).toBeNull(); }); it('localStorage 无 user 时应保持未认证', () => { localStorageStore['access_token'] = createFakeToken(['diary.journal.read']); // 不设置 user useAuthStore.getState().loadFromStorage(); const state = useAuthStore.getState(); expect(state.isAuthenticated).toBe(false); }); it('localStorage user JSON 损坏时应移除损坏数据并保持未认证', () => { localStorageStore['access_token'] = createFakeToken([]); localStorageStore['user'] = '{invalid json!!!'; useAuthStore.getState().loadFromStorage(); const state = useAuthStore.getState(); expect(state.isAuthenticated).toBe(false); expect(state.user).toBeNull(); // 损坏的 user 数据应被清理 expect(localStorage.removeItem).toHaveBeenCalledWith('user'); }); it('token 和 user 都为空时应返回干净的未认证状态', () => { useAuthStore.getState().loadFromStorage(); const state = useAuthStore.getState(); expect(state.user).toBeNull(); expect(state.isAuthenticated).toBe(false); expect(state.permissions).toEqual([]); }); }); // ========================================================================= // 状态重置验证 // ========================================================================= describe('状态重置', () => { it('登出后再次登录应正确设置新状态', async () => { // 第一次登录 const user1 = createFakeUser({ id: 'user-001', username: 'user1' }); const token1 = createFakeToken(['perm.a']); mockApiLogin.mockResolvedValue(createFakeLoginResponse({ user: user1, access_token: token1 })); await useAuthStore.getState().login('user1', 'pass1'); expect(useAuthStore.getState().user?.id).toBe('user-001'); expect(useAuthStore.getState().permissions).toEqual(['perm.a']); // 登出 mockApiLogout.mockResolvedValue(undefined); await useAuthStore.getState().logout(); expect(useAuthStore.getState().user).toBeNull(); expect(useAuthStore.getState().permissions).toEqual([]); // 第二次登录(不同用户、不同权限) const user2 = createFakeUser({ id: 'user-002', username: 'user2' }); const token2 = createFakeToken(['perm.b', 'perm.c']); mockApiLogin.mockResolvedValue(createFakeLoginResponse({ user: user2, access_token: token2 })); await useAuthStore.getState().login('user2', 'pass2'); expect(useAuthStore.getState().user?.id).toBe('user-002'); expect(useAuthStore.getState().permissions).toEqual(['perm.b', 'perm.c']); }); it('登出后的状态应与初始状态一致', async () => { // 登录 mockApiLogin.mockResolvedValue(createFakeLoginResponse()); await useAuthStore.getState().login('user', 'pass'); // 登出 mockApiLogout.mockResolvedValue(undefined); await useAuthStore.getState().logout(); const state = useAuthStore.getState(); expect(state.user).toBeNull(); expect(state.isAuthenticated).toBe(false); expect(state.loading).toBe(false); expect(state.permissions).toEqual([]); }); }); // ========================================================================= // 边界情况 // ========================================================================= describe('边界情况', () => { it('并发登录不应导致状态不一致', async () => { const user1 = createFakeUser({ id: 'user-001' }); const user2 = createFakeUser({ id: 'user-002' }); let resolveLogin1: (value: unknown) => void; let resolveLogin2: (value: unknown) => void; mockApiLogin.mockImplementationOnce(() => new Promise((r) => { resolveLogin1 = r; })); mockApiLogin.mockImplementationOnce(() => new Promise((r) => { resolveLogin2 = r; })); const login1 = useAuthStore.getState().login('user1', 'pass1'); const login2 = useAuthStore.getState().login('user2', 'pass2'); // 第二次登录先完成 resolveLogin2!(createFakeLoginResponse({ user: user2 })); await login2; expect(useAuthStore.getState().user?.id).toBe('user-002'); // 第一次登录后完成(覆盖第二次) resolveLogin1!(createFakeLoginResponse({ user: user1 })); await login1; expect(useAuthStore.getState().user?.id).toBe('user-001'); // 最终 loading 为 false expect(useAuthStore.getState().loading).toBe(false); }); it('JWT payload 含 permissions 非数组时应返回空权限', async () => { const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const payload = btoa(JSON.stringify({ permissions: 'not-an-array' })) .replace(/\+/g, '-') .replace(/\//g, '_'); const signature = btoa('sig'); const token = `${header}.${payload}.${signature}`; mockApiLogin.mockResolvedValue(createFakeLoginResponse({ access_token: token })); await useAuthStore.getState().login('user', 'pass'); expect(useAuthStore.getState().permissions).toEqual([]); }); }); });