385 lines
16 KiB
TypeScript
385 lines
16 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 } from 'react-router-dom';
|
|
import { Login } from '@/pages/Login';
|
|
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 renderLogin() {
|
|
return render(
|
|
<MemoryRouter initialEntries={['/login']}>
|
|
<Routes>
|
|
<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('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();
|
|
});
|
|
});
|