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( } /> Module Selection} /> , ); } 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() { let resolve!: (value: T) => void; const promise = new Promise((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'); expect(await screen.findByRole('checkbox', { name: '记住密码' })).toBeEnabled(); 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>(); const second = createDeferred>(); 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: '502 Bad Gateway secret' }; 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: '502 Bad Gatewayupstream secret' }); renderLogin(); await screen.findByRole('link', { name: '用户协议' }); expect(screen.getByRole('alert')).toHaveTextContent('登录服务暂时不可用,请稍后重试。'); expect(screen.queryByText(/upstream secret/i)).not.toBeInTheDocument(); }); });