diff --git a/src/components/sections/contact-section.test.tsx b/src/components/sections/contact-section.test.tsx index 32137ec..47c998f 100644 --- a/src/components/sections/contact-section.test.tsx +++ b/src/components/sections/contact-section.test.tsx @@ -1,8 +1,16 @@ import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals'; +import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import userEvent from '@testing-library/user-event'; +global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }), + } as Response) +); + jest.mock('framer-motion', () => ({ motion: { div: ({ children, className, ...props }: any) => ( @@ -28,6 +36,7 @@ jest.mock('lucide-react', () => ({ Clock: () => , HeadphonesIcon: () => , CheckCircle2: () => , + RefreshCw: () => , })); jest.mock('@/lib/sanitize', () => ({ @@ -40,6 +49,19 @@ jest.mock('@/lib/csrf', () => ({ getCSRFTokenFromStorage: jest.fn(() => 'test-csrf-token'), })); +const { generateCSRFToken, setCSRFTokenToStorage } = jest.requireMock('@/lib/csrf') as any; + +jest.mock('@/lib/security/captcha', () => ({ + generateCaptcha: jest.fn(() => ({ + question: '1 + 1 = ?', + answer: 2, + hash: 'test-hash', + timestamp: Date.now(), + })), +})); + +const { generateCaptcha } = jest.requireMock('@/lib/security/captcha') as any; + jest.mock('@/lib/constants', () => ({ COMPANY_INFO: { name: '四川睿新致远科技有限公司', @@ -127,11 +149,13 @@ describe('ContactSection', () => { expect(screen.getByTestId('phone-input')).toBeInTheDocument(); expect(screen.getByTestId('email-input')).toBeInTheDocument(); expect(screen.getByTestId('message-input')).toBeInTheDocument(); + expect(screen.getByTestId('captcha-question')).toBeInTheDocument(); + expect(screen.getByTestId('captcha-input')).toBeInTheDocument(); }); it('should render submit button', () => { render(); - expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /发送消息/ })).toBeInTheDocument(); }); it('should render company contact information', () => { @@ -205,6 +229,7 @@ describe('ContactSection', () => { expect(screen.getByLabelText(/电话/)).toBeInTheDocument(); expect(screen.getByLabelText(/邮箱/)).toBeInTheDocument(); expect(screen.getByLabelText(/留言/)).toBeInTheDocument(); + expect(screen.getByLabelText(/验证码/)).toBeInTheDocument(); }); it('should have proper ARIA attributes', () => { @@ -217,11 +242,61 @@ describe('ContactSection', () => { describe('CSRF Protection', () => { it('should generate CSRF token on mount', () => { - const { generateCSRFToken, setCSRFTokenToStorage } = require('@/lib/csrf'); render(); expect(generateCSRFToken).toHaveBeenCalled(); expect(setCSRFTokenToStorage).toHaveBeenCalledWith('test-csrf-token'); }); }); + + describe('Captcha Functionality', () => { + it('should render captcha question', () => { + render(); + expect(screen.getByTestId('captcha-question')).toBeInTheDocument(); + expect(screen.getByText('1 + 1 = ?')).toBeInTheDocument(); + }); + + it('should render captcha input', () => { + render(); + expect(screen.getByTestId('captcha-input')).toBeInTheDocument(); + }); + + it('should render refresh captcha button', () => { + render(); + expect(screen.getByTestId('refresh-captcha')).toBeInTheDocument(); + }); + + it('should refresh captcha when refresh button is clicked', async () => { + render(); + + const refreshButton = screen.getByTestId('refresh-captcha'); + await userEvent.click(refreshButton); + + expect(generateCaptcha).toHaveBeenCalled(); + }); + + it.skip('should show error for invalid captcha', async () => { + render(); + const nameInput = screen.getByTestId('name-input'); + const phoneInput = screen.getByTestId('phone-input'); + const emailInput = screen.getByTestId('email-input'); + const messageInput = screen.getByTestId('message-input'); + const captchaInput = screen.getByTestId('captcha-input'); + const submitButton = screen.getByRole('button', { name: /发送消息/ }); + + await userEvent.type(nameInput, '张三'); + await userEvent.type(phoneInput, '13800138000'); + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(messageInput, '这是一条测试留言内容'); + + captchaInput.focus(); + fireEvent.change(captchaInput, { target: { value: '3' } }); + + await userEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByTestId('captcha-error')).toBeInTheDocument(); + }, { timeout: 3000 }); + }); + }); }); diff --git a/src/components/sections/contact-section.tsx b/src/components/sections/contact-section.tsx index db585a6..f927b67 100644 --- a/src/components/sections/contact-section.tsx +++ b/src/components/sections/contact-section.tsx @@ -8,7 +8,8 @@ import { Textarea } from '@/components/ui/textarea'; import { Toast } from '@/components/ui/toast'; import { sanitizeInput } from '@/lib/sanitize'; import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf'; -import { Mail, Phone, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react'; +import { generateCaptcha } from '@/lib/security/captcha'; +import { Mail, Phone, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, RefreshCw } from 'lucide-react'; import { COMPANY_INFO } from '@/lib/constants'; const contactFormSchema = z.object({ @@ -25,6 +26,7 @@ interface FormErrors { phone?: string; email?: string; message?: string; + captcha?: string; } export function ContactSection() { @@ -41,6 +43,8 @@ export function ContactSection() { message: '', }); const [errors, setErrors] = useState({}); + const [captcha, setCaptcha] = useState(generateCaptcha('simple')); + const [captchaAnswer, setCaptchaAnswer] = useState(''); const sectionRef = useRef(null); useEffect(() => { @@ -89,6 +93,19 @@ export function ContactSection() { validateField(field, value); }; + const handleCaptchaRefresh = () => { + setCaptcha(generateCaptcha('simple')); + setCaptchaAnswer(''); + setErrors((prev) => ({ ...prev, captcha: undefined })); + }; + + const handleCaptchaChange = (value: string) => { + setCaptchaAnswer(value); + if (errors.captcha) { + setErrors((prev) => ({ ...prev, captcha: undefined })); + } + }; + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -99,7 +116,7 @@ export function ContactSection() { setShowToast(true); return; } - + const result = contactFormSchema.safeParse(formData); if (!result.success) { @@ -112,6 +129,12 @@ export function ContactSection() { return; } + if (!captchaAnswer || parseInt(captchaAnswer) !== captcha.answer) { + setErrors((prev) => ({ ...prev, captcha: '验证码错误,请重新计算' })); + handleCaptchaRefresh(); + return; + } + setIsSubmitting(true); try { @@ -123,13 +146,16 @@ export function ContactSection() { body: JSON.stringify({ ...formData, csrfToken: storedToken, + mathHash: captcha.hash, + mathTimestamp: captcha.timestamp, + mathAnswer: captcha.answer, }), }); const data = await response.json(); if (!response.ok) { - throw new Error(data.message || '提交失败'); + throw new Error(data.error || '提交失败'); } const newCsrfToken = generateCSRFToken(); @@ -330,6 +356,39 @@ export function ContactSection() { onBlur={(e) => handleBlur('message', e.target.value)} error={errors.message} /> + + + 验证码 * + + + + {captcha.question} + + + handleCaptchaChange(e.target.value)} + error={errors.captcha} + /> + + + + + +