问题:Main 刷新令牌返回 401 后已清空会话,但 Renderer 仍保留用户信息,AI 设计持续返回 AUTH_REQUIRED。 实现:认证初始化等待 Main 同步完成;同步或刷新失败清理登录态;Workspace 识别结构化认证错误并在重新登录后恢复加载;路由守卫响应登录状态变化。 验证:49 个聚焦测试通过,TypeScript、聚焦 Lint 与 Vite 生产构建通过。
332 lines
9.2 KiB
TypeScript
332 lines
9.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
|
}));
|
|
|
|
function resetAuthStore() {
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: '',
|
|
clientId: 'app',
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
tokenType: null,
|
|
expiresAt: null,
|
|
user: null,
|
|
});
|
|
}
|
|
|
|
describe('auth store', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-15T08:00:00.000Z'));
|
|
window.localStorage.clear();
|
|
hostApiFetchMock.mockReset();
|
|
resetAuthStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('starts browser authorization through the host api and stores the session', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
token: {
|
|
access_token: 'access-token',
|
|
refresh_token: 'refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 60,
|
|
username: 'zhangsan',
|
|
user_id: '1',
|
|
tenant_id: 7,
|
|
dept_id: 9,
|
|
authorities: ['ROLE_USER'],
|
|
client_id: 'app',
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({ success: true });
|
|
|
|
await useAuthStore.getState().loginWithBrowser();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', {
|
|
method: 'POST',
|
|
});
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated()).toBe(true);
|
|
expect(state.accessToken).toBe('access-token');
|
|
expect(state.refreshToken).toBe('refresh-token');
|
|
expect(state.expiresAt).toBe(Date.now() + 60_000);
|
|
expect(state.user).toEqual({
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
});
|
|
});
|
|
|
|
it('surfaces browser authorization failures and does not keep a partial session', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: false,
|
|
error: 'Authorization timed out',
|
|
});
|
|
|
|
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
|
|
'Authorization timed out',
|
|
);
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.loading).toBe(false);
|
|
expect(state.error).toBe('Authorization timed out');
|
|
expect(state.accessToken).toBeNull();
|
|
expect(state.isAuthenticated()).toBe(false);
|
|
});
|
|
|
|
it('clears a persisted session when the configured SSO gateway changes', () => {
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://onefeel.brother7.cn/ingress/auth',
|
|
clientId: 'app',
|
|
accessToken: 'old-environment-token',
|
|
refreshToken: 'old-environment-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
useAuthStore.getState().init();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.authBase).toBe('https://biz.nianxx.cn/auth/');
|
|
expect(state.accessToken).toBeNull();
|
|
expect(state.refreshToken).toBeNull();
|
|
expect(state.user).toBeNull();
|
|
expect(state.isAuthenticated()).toBe(false);
|
|
});
|
|
|
|
it('syncs a valid persisted session to Main during init', () => {
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'persisted-access-token',
|
|
refreshToken: 'persisted-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
useAuthStore.getState().init();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/sync', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
accessToken: 'persisted-access-token',
|
|
refreshToken: 'persisted-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('waits for Main session sync before marking persisted auth initialized', async () => {
|
|
let resolveSync!: (value: { success: boolean }) => void;
|
|
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveSync = resolve;
|
|
}));
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'persisted-access-token',
|
|
refreshToken: 'persisted-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
const initialization = useAuthStore.getState().init();
|
|
|
|
expect(useAuthStore.getState().initialized).toBe(false);
|
|
|
|
resolveSync({ success: true });
|
|
await initialization;
|
|
|
|
expect(useAuthStore.getState().initialized).toBe(true);
|
|
});
|
|
|
|
it('clears persisted renderer auth when Main session sync fails', async () => {
|
|
hostApiFetchMock.mockRejectedValueOnce(new Error('Host API unavailable'));
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'persisted-access-token',
|
|
refreshToken: 'persisted-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
error: '登录状态恢复失败,请重新登录。',
|
|
});
|
|
});
|
|
|
|
it('syncs a refreshed session to Main', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
token: {
|
|
access_token: 'new-access-token',
|
|
refresh_token: 'new-refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 120,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({ success: true });
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'old-access-token',
|
|
refreshToken: 'old-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 1_000,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await expect(useAuthStore.getState().refreshSession()).resolves.toBe('new-access-token');
|
|
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/refresh', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ refreshToken: 'old-refresh-token' }),
|
|
});
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/session/sync', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
accessToken: 'new-access-token',
|
|
refreshToken: 'new-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 120_000,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('clears stale renderer auth when refresh fails', async () => {
|
|
hostApiFetchMock.mockRejectedValueOnce(new Error('Invalid refresh token'));
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'expired-access-token',
|
|
refreshToken: 'invalid-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await expect(useAuthStore.getState().refreshSession()).resolves.toBeNull();
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
});
|
|
});
|
|
|
|
it('logs out remotely and clears the local session', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({ success: true });
|
|
useAuthStore.setState({
|
|
authBase: 'https://gateway.example.com/auth',
|
|
clientId: 'app',
|
|
accessToken: 'access-token',
|
|
refreshToken: 'refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().logout();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/logout', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
accessToken: 'access-token',
|
|
}),
|
|
});
|
|
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
|
|
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
expect(useAuthStore.getState().user).toBeNull();
|
|
});
|
|
});
|