Files
makelore/tests/unit/login-page.test.tsx
brother7 592bdfbc70 fix: 修复 AI 设计会话失效后的伪登录
问题:Main 刷新令牌返回 401 后已清空会话,但 Renderer 仍保留用户信息,AI 设计持续返回 AUTH_REQUIRED。

实现:认证初始化等待 Main 同步完成;同步或刷新失败清理登录态;Workspace 识别结构化认证错误并在重新登录后恢复加载;路由守卫响应登录状态变化。

验证:49 个聚焦测试通过,TypeScript、聚焦 Lint 与 Vite 生产构建通过。
2026-08-01 09:37:23 +08:00

406 lines
14 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Login } from '@/pages/Login';
import { useAuthStore } from '@/stores/auth';
import App from '@/App';
import { useSettingsStore } from '@/stores/settings';
import { useOpencodeStore } from '@/stores/opencode';
import { useProjectConfigStore } from '@/stores/project-config';
import { createProjectConfig } from '../../shared/project-config';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
function resetAuthStore() {
useAuthStore.setState({
initialized: true,
loading: false,
error: null,
authBase: '',
clientId: 'app',
accessToken: null,
refreshToken: null,
tokenType: null,
expiresAt: null,
user: null,
});
}
describe('Login page', () => {
beforeEach(() => {
window.electron.imageWorkspaceLocalDevelopment = false;
window.localStorage.clear();
hostApiFetchMock.mockReset();
resetAuthStore();
useSettingsStore.getState().resetSettings();
useOpencodeStore.setState({ projects: [], activeProject: null });
useProjectConfigStore.setState({ configsByProjectId: {}, knowledgeByProjectId: {}, loadingProjectId: null, errorsByProjectId: {} });
vi.unstubAllGlobals();
});
it('keeps the login route reachable from the app router', () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
render(
<MemoryRouter initialEntries={['/login']}>
<App />
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: 'Continue in browser' })).toBeInTheDocument();
});
it('redirects protected app routes to login when setup is complete but the user is signed out', async () => {
useSettingsStore.setState({ setupComplete: true });
render(
<MemoryRouter initialEntries={['/models']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByRole('button', { name: 'Continue in browser' })).toBeInTheDocument();
});
it('redirects an open protected route when the current auth session is invalidated', async () => {
useSettingsStore.setState({ setupComplete: true });
useAuthStore.setState({
initialized: true,
loading: false,
error: null,
authBase: 'https://biz.nianxx.cn/auth/',
clientId: 'app',
accessToken: 'access-token',
refreshToken: 'refresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
user: {
username: 'brother7',
userId: '1',
tenantId: null,
deptId: null,
authorities: [],
},
});
hostApiFetchMock.mockResolvedValue({ success: true });
render(
<MemoryRouter initialEntries={['/makelore-home']}>
<App />
</MemoryRouter>,
);
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/session/sync',
expect.any(Object),
));
act(() => {
useAuthStore.setState({
accessToken: null,
refreshToken: null,
tokenType: null,
expiresAt: null,
user: null,
});
});
expect(await screen.findByRole('button', { name: 'Continue in browser' })).toBeInTheDocument();
});
it('opens only the local image workspace anonymously when the explicit development mode is active', async () => {
useSettingsStore.setState({ setupComplete: true });
window.electron.imageWorkspaceLocalDevelopment = true;
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/works/image-workspace') {
return {
success: true,
status: 200,
data: {
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
},
workspaces: [],
},
};
}
return { success: true };
});
const { unmount } = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument();
expect(screen.queryByText('本地开发')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Continue in browser' })).not.toBeInTheDocument();
unmount();
render(
<MemoryRouter initialEntries={['/models']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByRole('button', { name: 'Continue in browser' })).toBeInTheDocument();
});
it('opens the project conversation from the default route for an initialized active project', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
const project = {
id: 'project-default-chat',
path: '/tmp/project-default-chat',
name: 'default-chat',
createdAt: '2026-07-12T00:00:00.000Z',
updatedAt: '2026-07-12T00:00:00.000Z',
lastOpenedAt: '2026-07-12T00:00:00.000Z',
};
const config = { ...createProjectConfig(), initialized: true };
useOpencodeStore.setState({ projects: [project], activeProject: project });
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: project };
if (path.startsWith('/api/opencode/projects/config?')) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 };
if (path === '/api/opencode/config-summary') return { providerIds: [], providerCount: 0, envKeys: [] };
if (path === '/api/provider-accounts') return [];
if (path === '/api/provider-accounts/key-info') return [];
if (path === '/api/provider-vendors') return [];
if (path === '/api/provider-accounts/default') return { accountId: null };
return { success: true };
});
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByTestId('chat-operation-page')).toBeInTheDocument();
});
it('keeps the default route on project configuration without an active project', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 };
if (path === '/api/opencode/config-summary') return { providerIds: [], providerCount: 0, envKeys: [] };
if (path === '/api/provider-accounts') return [];
if (path === '/api/provider-accounts/key-info') return [];
if (path === '/api/provider-vendors') return [];
if (path === '/api/provider-accounts/default') return { accountId: null };
return { success: true };
});
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByText('一念成光,万物可创。')).toBeInTheDocument();
expect(screen.queryByText('请先新建项目')).not.toBeInTheDocument();
expect(screen.queryByTestId('chat-operation-page')).not.toBeInTheDocument();
});
it('keeps an uninitialized active project on project configuration', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
const project = {
id: 'project-uninitialized',
path: '/tmp/project-uninitialized',
name: 'uninitialized',
createdAt: '2026-07-12T00:00:00.000Z',
updatedAt: '2026-07-12T00:00:00.000Z',
lastOpenedAt: '2026-07-12T00:00:00.000Z',
};
const config = { ...createProjectConfig(), initialized: false };
useOpencodeStore.setState({ projects: [project], activeProject: project });
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: project };
if (path.startsWith('/api/opencode/projects/config?')) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 };
if (path === '/api/opencode/config-summary') return { providerIds: [], providerCount: 0, envKeys: [] };
if (path === '/api/provider-accounts') return [];
if (path === '/api/provider-accounts/key-info') return [];
if (path === '/api/provider-vendors') return [];
if (path === '/api/provider-accounts/default') return { accountId: null };
return { success: true };
});
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByText('配置你的项目空间')).toBeInTheDocument();
expect(screen.queryByTestId('chat-operation-page')).not.toBeInTheDocument();
});
it('starts browser authorization and enters the app after success', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/browser/start') {
return {
success: true,
token: {
access_token: 'access-token',
refresh_token: 'refresh-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
},
};
}
if (path === '/api/auth/session/sync') return { success: true };
if (path === '/api/provider-accounts/import-user-model-config') {
return {
success: true,
account: {
id: 'niancode-user-models',
vendorId: 'custom',
label: 'Makelore Models',
authMode: 'api_key',
model: 'gpt-4.1-mini',
enabled: true,
isDefault: true,
createdAt: '2026-07-07T00:00:00.000Z',
updatedAt: '2026-07-07T00:00:00.000Z',
},
importedModels: ['gpt-4.1-mini'],
};
}
if (path === '/api/provider-accounts') return [];
if (path === '/api/provider-accounts/key-info') return [];
if (path === '/api/provider-vendors') return [];
if (path === '/api/provider-accounts/default') return { accountId: null };
return { success: true };
});
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
expect(screen.queryByLabelText('Username')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Captcha')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Continue in browser' }));
await screen.findByText('Module Selection');
await waitFor(() => {
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', {
method: 'POST',
});
});
});
it('syncs the current Works Square user model config after browser login', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/browser/start') {
return {
success: true,
token: {
access_token: 'fresh-access-token',
refresh_token: 'refresh-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
},
};
}
if (path === '/api/auth/session/sync') {
return { success: true };
}
if (path === '/api/provider-accounts/import-user-model-config') {
return {
success: true,
account: {
id: 'niancode-user-models',
vendorId: 'custom',
label: 'Makelore Models',
authMode: 'api_key',
model: 'gpt-4.1-mini',
fallbackModels: ['claude-3-5-haiku'],
enabled: true,
isDefault: true,
createdAt: '2026-07-07T00:00:00.000Z',
updatedAt: '2026-07-07T00:00:00.000Z',
},
importedModels: ['gpt-4.1-mini', 'claude-3-5-haiku'],
};
}
if (path === '/api/provider-accounts/default') {
return { accountId: 'niancode-user-models' };
}
return [];
});
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('button', { name: 'Continue in browser' }));
await waitFor(() => {
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', {
method: 'POST',
body: JSON.stringify({ accessToken: 'fresh-access-token' }),
});
});
});
it('keeps the user on login when model config sync fails after browser login', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
token: {
access_token: 'fresh-access-token',
refresh_token: 'refresh-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
},
})
.mockResolvedValueOnce({ success: true })
.mockRejectedValueOnce(new Error('sync failed'))
.mockResolvedValueOnce({ success: true });
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('button', { name: 'Continue in browser' }));
expect(await screen.findByText('sync failed')).toBeInTheDocument();
expect(screen.queryByText('Module Selection')).not.toBeInTheDocument();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/logout', {
method: 'POST',
body: JSON.stringify({ accessToken: 'fresh-access-token' }),
});
});
});