Files
makelore/tests/unit/login-page.test.tsx

470 lines
20 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { Login } from '@/pages/Login';
import { dispatchPluginWorkspaceCommand } from '@/pages/Plugins/plugin-workspace-controller';
import { useAuthStore } from '@/stores/auth';
import { useProviderStore } from '@/stores/providers';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
const loginWithPassword = vi.fn();
const loginWithMobile = vi.fn();
const logout = vi.fn();
const importUserModelConfig = vi.fn();
function resetAuthStore() {
useAuthStore.setState({
initialized: true,
loading: false,
error: null,
authBase: '',
clientId: 'app',
accessToken: null,
tokenType: null,
expiresAt: null,
lastActiveAt: null,
canRefresh: false,
legacyRefreshToken: null,
user: null,
loginWithPassword,
loginWithMobile,
logout,
});
}
function LocationProbe() {
const location = useLocation();
return <output data-testid="login-destination">{location.pathname}{location.search}</output>;
}
function PluginSignInAction() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output data-testid="login-destination">{location.pathname}{location.search}</output>
<button
type="button"
onClick={() => void dispatchPluginWorkspaceCommand({ kind: 'sign_in' }, {
marketplace: {
acquire: async () => undefined,
remove: async () => undefined,
install: async () => undefined,
installBeta: async () => undefined,
update: async () => undefined,
uninstall: async () => undefined,
},
device: {
setEnabled: async () => undefined,
uninstall: async () => undefined,
},
project: { setEnabled: async () => undefined },
loginReturnPath: `${location.pathname}${location.search}`,
navigate,
openSettings: () => undefined,
})}
>
</button>
</>
);
}
function renderLogin(initialEntries: React.ComponentProps<typeof MemoryRouter>['initialEntries'] = ['/login']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
<Route path="/project-config/plugins" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
}
function renderPluginLoginJourney(canonicalPath: string) {
return render(
<MemoryRouter initialEntries={[canonicalPath]}>
<Routes>
<Route path="/project-config/plugins" element={<PluginSignInAction />} />
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
}
function switchToMobile() {
fireEvent.click(screen.getByRole('tab', { name: '验证码登录' }));
}
function resolveCaptcha(randomStr = 'captcha-id') {
return {
success: true,
image: { mimeType: 'image/png', dataBase64: `image-${randomStr}` },
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
describe('Login page', () => {
beforeEach(() => {
window.localStorage.clear();
vi.clearAllMocks();
vi.useRealTimers();
resetAuthStore();
useProviderStore.setState({ importUserModelConfig });
importUserModelConfig.mockResolvedValue(undefined);
logout.mockResolvedValue(undefined);
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') {
return {
success: true,
links: {
termsUrl: 'https://works.example/terms',
privacyUrl: 'https://works.example/privacy',
forgotPasswordUrl: 'https://works.example/forgot',
},
};
}
if (path === '/api/auth/remembered-password') {
return { success: true, available: true, credentials: null };
}
if (path.startsWith('/api/auth/mobile-image-code?')) return resolveCaptcha();
return { success: true };
});
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000001');
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('shows native Chinese tabs with password login as the default and no browser authorization surface', async () => {
renderLogin();
await screen.findByRole('link', { name: '用户协议' });
expect(screen.getByRole('tab', { name: '密码登录' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: '验证码登录' })).toHaveAttribute('aria-selected', 'false');
expect(screen.getByLabelText('用户名')).toHaveAttribute('autocomplete', 'username');
expect(screen.getByLabelText('密码')).toHaveAttribute('autocomplete', 'current-password');
const rememberPasswordCheckbox = await screen.findByRole('checkbox', { name: '记住密码' });
const loginButton = screen.getByRole('button', { name: '登录' });
expect(rememberPasswordCheckbox).toBeEnabled();
expect(
loginButton.compareDocumentPosition(rememberPasswordCheckbox)
& Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(screen.queryByText(/浏览器|微信|注册/)).not.toBeInTheDocument();
});
it('restores OS-protected password credentials and keeps remember password selected', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path === '/api/auth/remembered-password') {
return {
success: true,
available: true,
credentials: { username: 'remembered-user', password: 'remembered-secret' },
};
}
return { success: true };
});
renderLogin();
await waitFor(() => expect(screen.getByLabelText('用户名')).toHaveValue('remembered-user'));
expect(screen.getByLabelText('密码')).toHaveValue('remembered-secret');
expect(screen.getByRole('checkbox', { name: '记住密码' })).toBeChecked();
expect(window.localStorage.getItem('niancode-auth')).not.toContain('remembered-secret');
});
it('renders only safe projected links and degrades unavailable or unsafe links to plain text', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: true,
links: {
termsUrl: null,
privacyUrl: 'javascript:alert(1)',
forgotPasswordUrl: null,
},
});
renderLogin();
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/public-config', { cache: 'no-store' }));
const agreement = screen.getByRole('checkbox', { name: /我已阅读并同意/ });
expect(agreement.closest('label')).toHaveTextContent('用户协议');
expect(agreement.closest('label')).toHaveTextContent('隐私政策');
expect(screen.queryByText('忘记密码?')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('opens projected agreement and recovery links with safe external-link attributes', async () => {
renderLogin();
for (const name of ['用户协议', '隐私政策', '忘记密码?']) {
const link = await screen.findByRole('link', { name });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
}
});
it('gates password login on agreement and submits the exact credentials before syncing models', async () => {
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});
renderLogin();
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: ' zhangsan ' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
fireEvent.click(await screen.findByRole('checkbox', { name: '记住密码' }));
const submit = screen.getByRole('button', { name: '登录' });
expect(submit).toBeDisabled();
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
expect(submit).toBeEnabled();
fireEvent.click(submit);
await screen.findByText('Module Selection');
expect(loginWithPassword).toHaveBeenCalledWith({
username: 'zhangsan',
password: 'secret-password',
rememberPassword: true,
});
expect(importUserModelConfig).toHaveBeenCalledWith('password-access-token');
});
it('returns a signed-out plugin action to the exact canonical workspace query after login', async () => {
const canonicalPath = '/project-config/plugins?scope=all&source=official&state=available&q=notes&plugin=official%3Amakelore.notes';
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});
renderPluginLoginJourney(canonicalPath);
fireEvent.click(screen.getByRole('button', { name: '登录后获取插件' }));
fireEvent.change(await screen.findByLabelText('用户名'), { target: { value: 'zhangsan' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByTestId('login-destination')).toHaveTextContent(canonicalPath);
});
it.each([
'https://attacker.example/plugins',
'//attacker.example/plugins',
])('rejects unsafe login return %s and keeps the module chooser default', async (unsafeReturn) => {
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});
renderLogin([{ pathname: '/login', state: { from: unsafeReturn } }]);
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'zhangsan' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('Module Selection')).toBeVisible();
});
it('validates a Chinese mobile number, uses one-time-code autocomplete, and submits only phone and SMS code', async () => {
loginWithMobile.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'mobile-access-token' });
});
renderLogin();
switchToMobile();
await screen.findByAltText('图形验证码');
expect(screen.getByLabelText('短信验证码')).toHaveAttribute('autocomplete', 'one-time-code');
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '20123456789' } });
fireEvent.change(screen.getByLabelText('短信验证码'), { target: { value: '123456' } });
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
expect(screen.getByRole('button', { name: '登录' })).toBeDisabled();
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await screen.findByText('Module Selection');
expect(loginWithMobile).toHaveBeenCalledWith({ phone: '13800138000', code: '123456' });
expect(importUserModelConfig).toHaveBeenCalledWith('mobile-access-token');
});
it('fetches an uncached UUID captcha and sends the exact image challenge without consuming an SMS code response', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path.includes('/mobile-image-code?')) return resolveCaptcha();
if (path === '/api/auth/mobile-code') return { success: true, code: 'server-mock-code-must-not-be-used' };
return { success: true };
});
renderLogin();
switchToMobile();
await screen.findByAltText('图形验证码');
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000001',
{ cache: 'no-store' },
);
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: ' AbCd ' } });
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/mobile-code', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
phone: '13800138000',
imageRandomStr: '00000000-0000-4000-8000-000000000001',
imageCode: 'AbCd',
}),
}));
expect(screen.getByLabelText('短信验证码')).toHaveValue('');
expect(screen.getByRole('button', { name: '60 秒' })).toBeDisabled();
expect(screen.queryByAltText('图形验证码')).not.toBeInTheDocument();
});
it('rejects a stale captcha response after manual refresh', async () => {
const first = createDeferred<ReturnType<typeof resolveCaptcha>>();
const second = createDeferred<ReturnType<typeof resolveCaptcha>>();
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
hostApiFetchMock.mockImplementation((path: string) => {
if (path === '/api/auth/public-config') return Promise.resolve({ success: true, links: {} });
if (path.includes('000000000001')) return first.promise;
if (path.includes('000000000002')) return second.promise;
return Promise.resolve({ success: true });
});
renderLogin();
switchToMobile();
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(expect.stringContaining('000000000001'), { cache: 'no-store' }));
fireEvent.click(screen.getByRole('button', { name: '刷新图形验证码' }));
await act(async () => second.resolve({ success: true, image: { mimeType: 'image/png', dataBase64: 'new-image' } }));
expect(await screen.findByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,new-image');
await act(async () => first.resolve({ success: true, image: { mimeType: 'image/png', dataBase64: 'stale-image' } }));
expect(screen.getByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,new-image');
});
it('loads a fresh UUID captcha each time the user switches into SMS login outside cooldown', async () => {
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path.includes('000000000001')) {
return { success: true, image: { mimeType: 'image/png', dataBase64: 'first-image' } };
}
if (path.includes('000000000002')) {
return { success: true, image: { mimeType: 'image/png', dataBase64: 'second-image' } };
}
return { success: true };
});
renderLogin();
switchToMobile();
expect(await screen.findByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,first-image');
fireEvent.click(screen.getByRole('tab', { name: '密码登录' }));
switchToMobile();
await waitFor(() => expect(screen.getByAltText('图形验证码')).toHaveAttribute(
'src',
'data:image/png;base64,second-image',
));
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000002',
{ cache: 'no-store' },
);
});
it('immediately replaces the image challenge when SMS sending fails and presents a safe error', async () => {
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path.includes('/mobile-image-code?')) return resolveCaptcha(path);
if (path === '/api/auth/mobile-code') return { success: false, error: '<html>502 Bad Gateway secret</html>' };
return { success: true };
});
renderLogin();
switchToMobile();
await screen.findByAltText('图形验证码');
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: 'abcd' } });
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
expect.stringContaining('000000000002'),
{ cache: 'no-store' },
));
expect(screen.getByRole('alert')).toHaveTextContent('验证码发送失败,请稍后重试。');
expect(screen.getByLabelText('图形验证码')).toHaveValue('');
});
it('starts a 60-second cooldown after SMS send and fetches a fresh challenge when it expires', async () => {
vi.useFakeTimers();
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
renderLogin();
switchToMobile();
await act(async () => Promise.resolve());
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: 'abcd' } });
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
await act(async () => Promise.resolve());
expect(screen.getByRole('button', { name: '60 秒' })).toBeDisabled();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000002',
{ cache: 'no-store' },
);
expect(screen.getByAltText('图形验证码')).toBeInTheDocument();
});
it('logs out and stays on login when post-login model synchronization fails', async () => {
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'fresh-access-token' });
});
importUserModelConfig.mockRejectedValue(new Error('sync failed with token fresh-access-token'));
logout.mockImplementation(async () => {
useAuthStore.setState({ accessToken: null });
});
renderLogin();
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'zhangsan' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请稍后重试。');
expect(screen.queryByText('fresh-access-token')).not.toBeInTheDocument();
expect(screen.queryByText('Module Selection')).not.toBeInTheDocument();
expect(logout).toHaveBeenCalledOnce();
});
it('keeps upstream HTML errors concise without exposing their contents', async () => {
useAuthStore.setState({ error: '<html><head><title>502 Bad Gateway</title></head><body>upstream secret</body></html>' });
renderLogin();
await screen.findByRole('link', { name: '用户协议' });
expect(screen.getByRole('alert')).toHaveTextContent('登录服务暂时不可用,请稍后重试。');
expect(screen.queryByText(/upstream secret/i)).not.toBeInTheDocument();
});
});