Files
nj/apps/web/src/stores/auth.test.ts
iven 85d6781372
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix: Phase 1.3 完善修复 — 管理端对接 + HMS清理 + 编辑器加载
- feat(web): ClassList.tsx 对接 update/deactivate/reset-code API
  - 编辑班级: PUT /diary/classes/:id
  - 停用班级: PATCH /diary/classes/:id/deactivate (Popconfirm 确认)
  - 重置班级码: POST /diary/classes/:id/reset-code (Popconfirm 确认)
  - 数据源改用 listAll() 获取所有班级
- fix(web): JournalList.tsx 班级筛选改用 classApi.listAll()
- fix(app): EditorPage 加载已有日记数据 (journalId 非空时)
  - 从 Isar 恢复笔画/元素/标签/心情/标题
  - _EditorView 改为 StatefulWidget + initState 加载
- chore(web): HMS 遗留代码清理
  - 删除 api/copilot.ts, healthFixtures.ts, healthHandlers.ts
  - AuditLogViewer 资源类型替换为日记模块类型
  - auth.test.ts / renderWithProviders 权限码 health.* → diary.*
- docs: 确认 M6 NotificationService 为误报 (已在 3 处调用)
2026-06-02 22:54:09 +08:00

491 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string> = {};
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, unknown> = {}): 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> = {}): 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> = {}): 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([]);
});
});
});