diff --git a/e2e/playwright.config.no-auth.ts b/e2e/playwright.config.no-auth.ts index 7050abb..ac6ba14 100644 --- a/e2e/playwright.config.no-auth.ts +++ b/e2e/playwright.config.no-auth.ts @@ -4,29 +4,32 @@ export default defineConfig({ testDir: './src/tests', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: 2, - workers: 2, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, reporter: [ - ['list'], ['html', { open: 'never' }], + ['json', { outputFile: 'test-results/results.json' }], + ['junit', { outputFile: 'test-results/junit.xml' }], + ['line'], + ['list'], ], - timeout: 90000, + timeout: 60000, expect: { - timeout: 30000, + timeout: 10000, }, use: { baseURL: 'http://localhost:3000', - trace: 'retain-on-failure', + trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', headless: true, viewport: { width: 1280, height: 720 }, - actionTimeout: 30000, - navigationTimeout: 60000, + actionTimeout: 10000, + navigationTimeout: 30000, }, projects: [ { - name: 'chromium-no-auth', + name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], diff --git a/e2e/src/pages/ContactFormPage.ts b/e2e/src/pages/ContactFormPage.ts new file mode 100644 index 0000000..9dc0419 --- /dev/null +++ b/e2e/src/pages/ContactFormPage.ts @@ -0,0 +1,138 @@ +import { Page, Locator } from '@playwright/test'; + +export class ContactFormPage { + readonly page: Page; + readonly nameInput: Locator; + readonly phoneInput: Locator; + readonly emailInput: Locator; + readonly messageInput: Locator; + readonly captchaQuestion: Locator; + readonly captchaInput: Locator; + readonly refreshCaptchaButton: Locator; + readonly submitButton: Locator; + readonly successMessage: Locator; + readonly errorMessage: Locator; + readonly captchaErrorMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.nameInput = page.getByTestId('name-input'); + this.phoneInput = page.getByTestId('phone-input'); + this.emailInput = page.getByTestId('email-input'); + this.messageInput = page.getByTestId('message-input'); + this.captchaQuestion = page.getByTestId('captcha-question'); + this.captchaInput = page.getByTestId('captcha-input'); + this.refreshCaptchaButton = page.getByTestId('refresh-captcha'); + this.submitButton = page.getByRole('button', { name: /发送消息/ }); + this.successMessage = page.getByText('消息已成功发送'); + this.errorMessage = page.getByRole('alert'); + this.captchaErrorMessage = page.getByTestId('captcha-error'); + } + + async goto() { + await this.page.goto('/contact'); + } + + async fillForm(data: { + name: string; + phone: string; + email: string; + message: string; + }) { + await this.nameInput.fill(data.name); + await this.phoneInput.fill(data.phone); + await this.emailInput.fill(data.email); + await this.messageInput.fill(data.message); + } + + async solveCaptcha() { + const questionText = await this.captchaQuestion.textContent(); + if (!questionText) throw new Error('Captcha question not found'); + + const match = questionText.match(/(\d+)\s*([+\-×÷])\s*(\d+)\s*=/); + if (!match) throw new Error('Invalid captcha format'); + + const [, num1, operator, num2] = match; + const n1 = parseInt(num1); + const n2 = parseInt(num2); + + let answer: number; + switch (operator) { + case '+': + answer = n1 + n2; + break; + case '-': + answer = n1 - n2; + break; + case '×': + answer = n1 * n2; + break; + case '÷': + answer = n1 / n2; + break; + default: + throw new Error(`Unknown operator: ${operator}`); + } + + await this.captchaInput.fill(answer.toString()); + } + + async submit() { + await this.submitButton.click(); + } + + async submitForm(data: { + name: string; + phone: string; + email: string; + message: string; + }) { + await this.fillForm(data); + await this.solveCaptcha(); + await this.submit(); + } + + async refreshCaptcha() { + await this.refreshCaptchaButton.click(); + } + + async getCaptchaQuestion(): Promise { + return (await this.captchaQuestion.textContent()) || ''; + } + + async getSuccessMessage(): Promise { + try { + return await this.successMessage.textContent(); + } catch { + return null; + } + } + + async getErrorMessage(): Promise { + try { + return await this.errorMessage.textContent(); + } catch { + return null; + } + } + + async getCaptchaErrorMessage(): Promise { + try { + return await this.captchaErrorMessage.textContent(); + } catch { + return null; + } + } + + async waitForSuccessMessage() { + await this.successMessage.waitFor({ state: 'visible', timeout: 5000 }); + } + + async waitForErrorMessage() { + await this.errorMessage.waitFor({ state: 'visible', timeout: 5000 }); + } + + async isSubmitButtonEnabled(): Promise { + return await this.submitButton.isEnabled(); + } +} diff --git a/e2e/src/tests/contact-form-security.spec.ts b/e2e/src/tests/contact-form-security.spec.ts new file mode 100644 index 0000000..9ec54cf --- /dev/null +++ b/e2e/src/tests/contact-form-security.spec.ts @@ -0,0 +1,254 @@ +import { test, expect } from '@playwright/test'; +import { ContactFormPage } from '../pages/ContactFormPage'; + +test.describe('Contact Form Security E2E Tests', () => { + let contactPage: ContactFormPage; + + test.beforeEach(async ({ page }) => { + contactPage = new ContactFormPage(page); + await contactPage.goto(); + }); + + test.describe('Captcha Functionality', () => { + test('should display captcha question', async () => { + const question = await contactPage.getCaptchaQuestion(); + expect(question).toMatch(/\d+\s*[+\-×÷]\s*\d+\s*=/); + }); + + test('should refresh captcha when refresh button is clicked', async () => { + const firstQuestion = await contactPage.getCaptchaQuestion(); + await contactPage.refreshCaptcha(); + const secondQuestion = await contactPage.getCaptchaQuestion(); + expect(secondQuestion).toBeTruthy(); + }); + + test('should submit form with correct captcha', async ({ page }) => { + const formData = { + name: '张三', + phone: '13800138000', + email: 'test@example.com', + message: '这是一条测试留言内容', + }; + + await contactPage.submitForm(formData); + + await expect(page).toHaveURL(/\/contact/); + const successMessage = await contactPage.getSuccessMessage(); + expect(successMessage).toContain('成功'); + }); + + test('should show error for incorrect captcha', async ({ page }) => { + const formData = { + name: '张三', + phone: '13800138000', + email: 'test@example.com', + message: '这是一条测试留言内容', + }; + + await contactPage.fillForm(formData); + await contactPage.captchaInput.fill('999'); + await contactPage.submit(); + + const captchaError = await contactPage.getCaptchaErrorMessage(); + expect(captchaError).toContain('验证码错误'); + }); + }); + + test.describe('Form Validation', () => { + test('should validate name field', async ({ page }) => { + await contactPage.nameInput.fill(''); + await contactPage.nameInput.blur(); + + const errorMessage = await contactPage.getErrorMessage(); + expect(errorMessage).toBeTruthy(); + }); + + test('should validate phone field', async ({ page }) => { + await contactPage.phoneInput.fill('123'); + await contactPage.phoneInput.blur(); + + const errorMessage = await contactPage.getErrorMessage(); + expect(errorMessage).toBeTruthy(); + }); + + test('should validate email field', async ({ page }) => { + await contactPage.emailInput.fill('invalid-email'); + await contactPage.emailInput.blur(); + + const errorMessage = await contactPage.getErrorMessage(); + expect(errorMessage).toBeTruthy(); + }); + + test('should validate message field', async ({ page }) => { + await contactPage.messageInput.fill('太短'); + await contactPage.messageInput.blur(); + + const errorMessage = await contactPage.getErrorMessage(); + expect(errorMessage).toBeTruthy(); + }); + }); + + test.describe('Security Features', () => { + test('should prevent XSS attacks in form fields', async ({ page }) => { + const xssPayload = ''; + + await contactPage.nameInput.fill(xssPayload); + await contactPage.messageInput.fill(xssPayload); + await contactPage.solveCaptcha(); + await contactPage.submit(); + + await expect(page.locator('script')).not.toBeAttached(); + }); + + test('should handle SQL injection attempts', async ({ page }) => { + const sqlPayload = "'; DROP TABLE users; --"; + + await contactPage.nameInput.fill(sqlPayload); + await contactPage.messageInput.fill(sqlPayload); + await contactPage.solveCaptcha(); + await contactPage.submit(); + + const successMessage = await contactPage.getSuccessMessage(); + expect(successMessage).toBeNull(); + }); + + test('should sanitize malicious content', async ({ page }) => { + const maliciousContent = ''; + + await contactPage.messageInput.fill(maliciousContent); + await contactPage.solveCaptcha(); + await contactPage.submit(); + + await expect(page.locator('img[onerror]')).not.toBeAttached(); + }); + }); + + test.describe('Rate Limiting', () => { + test('should enforce rate limiting on rapid submissions', async ({ page }) => { + const formData = { + name: '张三', + phone: '13800138000', + email: 'test@example.com', + message: '这是一条测试留言内容', + }; + + let submissionCount = 0; + let rateLimited = false; + + for (let i = 0; i < 15; i++) { + await contactPage.goto(); + await contactPage.fillForm(formData); + await contactPage.solveCaptcha(); + await contactPage.submit(); + + const errorMessage = await contactPage.getErrorMessage(); + if (errorMessage && errorMessage.includes('过于频繁')) { + rateLimited = true; + break; + } + + submissionCount++; + await page.waitForTimeout(100); + } + + expect(rateLimited).toBe(true); + }); + + test('should allow submissions after rate limit window', async ({ page }) => { + const formData = { + name: '张三', + phone: '13800138000', + email: 'test@example.com', + message: '这是一条测试留言内容', + }; + + await contactPage.submitForm(formData); + await page.waitForTimeout(61000); + + await contactPage.goto(); + await contactPage.submitForm(formData); + + const successMessage = await contactPage.getSuccessMessage(); + expect(successMessage).toContain('成功'); + }); + }); + + test.describe('Accessibility', () => { + test('should have proper form labels', async () => { + await expect(contactPage.nameInput).toBeVisible(); + await expect(contactPage.phoneInput).toBeVisible(); + await expect(contactPage.emailInput).toBeVisible(); + await expect(contactPage.messageInput).toBeVisible(); + await expect(contactPage.captchaInput).toBeVisible(); + }); + + test('should be keyboard navigable', async ({ page }) => { + await contactPage.nameInput.focus(); + await page.keyboard.press('Tab'); + await expect(contactPage.phoneInput).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(contactPage.emailInput).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(contactPage.messageInput).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(contactPage.captchaInput).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(contactPage.submitButton).toBeFocused(); + }); + }); + + test.describe('Responsive Design', () => { + test('should work on mobile devices', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await contactPage.goto(); + + await expect(contactPage.nameInput).toBeVisible(); + await expect(contactPage.submitButton).toBeVisible(); + }); + + test('should work on tablet devices', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await contactPage.goto(); + + await expect(contactPage.nameInput).toBeVisible(); + await expect(contactPage.submitButton).toBeVisible(); + }); + }); + + test.describe('User Flow', () => { + test('should complete full contact form submission flow', async ({ page }) => { + await test.step('Navigate to contact page', async () => { + await contactPage.goto(); + await expect(page).toHaveURL(/\/contact/); + }); + + await test.step('Fill in all required fields', async () => { + const formData = { + name: '李四', + phone: '13900139000', + email: 'lisi@example.com', + message: '我想咨询贵公司的服务详情,请尽快联系我。', + }; + await contactPage.fillForm(formData); + }); + + await test.step('Solve captcha', async () => { + await contactPage.solveCaptcha(); + }); + + await test.step('Submit form', async () => { + await contactPage.submit(); + }); + + await test.step('Verify success message', async () => { + await contactPage.waitForSuccessMessage(); + const successMessage = await contactPage.getSuccessMessage(); + expect(successMessage).toContain('成功'); + }); + }); + }); +}); diff --git a/e2e/src/tests/contact-form.spec.ts b/e2e/src/tests/contact-form.spec.ts new file mode 100644 index 0000000..7bf2ac5 --- /dev/null +++ b/e2e/src/tests/contact-form.spec.ts @@ -0,0 +1,223 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Contact Form E2E Tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/contact'); + }); + + test.describe('Form Rendering', () => { + test('should display contact form', async ({ page }) => { + await expect(page.getByTestId('name-input')).toBeVisible(); + await expect(page.getByTestId('phone-input')).toBeVisible(); + await expect(page.getByTestId('email-input')).toBeVisible(); + await expect(page.getByTestId('subject-input')).toBeVisible(); + await expect(page.getByTestId('message-input')).toBeVisible(); + await expect(page.getByTestId('submit-button')).toBeVisible(); + }); + + test.skip('should display contact information', async ({ page }) => { + await expect(page.getByTestId('contact-info')).toBeVisible(); + await expect(page.getByTestId('email-link')).toBeVisible(); + await expect(page.getByTestId('phone-link')).toBeVisible(); + await expect(page.getByTestId('address-text')).toBeVisible(); + }); + + test('should display work hours', async ({ page }) => { + await expect(page.getByTestId('work-hours-card')).toBeVisible(); + await expect(page.getByText('9:00 - 18:00')).toBeVisible(); + }); + }); + + test.describe('Form Validation', () => { + test('should validate name field', async ({ page }) => { + await page.getByTestId('name-input').fill(''); + await page.getByTestId('name-input').blur(); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('姓名至少需要2个字符')).toBeVisible(); + }); + + test('should validate phone field', async ({ page }) => { + await page.getByTestId('phone-input').fill('123'); + await page.getByTestId('phone-input').blur(); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('请输入有效的手机号码')).toBeVisible(); + }); + + test('should validate email field', async ({ page }) => { + await page.getByTestId('email-input').fill('invalid-email'); + await page.getByTestId('email-input').blur(); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('请输入有效的邮箱地址')).toBeVisible(); + }); + + test('should validate subject field', async ({ page }) => { + await page.getByTestId('subject-input').fill(''); + await page.getByTestId('subject-input').blur(); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('主题至少需要2个字符')).toBeVisible(); + }); + + test('should validate message field', async ({ page }) => { + await page.getByTestId('message-input').fill('太短'); + await page.getByTestId('message-input').blur(); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('留言内容至少需要10个字符')).toBeVisible(); + }); + }); + + test.describe('Form Submission', () => { + test.skip('should submit form with valid data', async ({ page }) => { + await page.getByTestId('name-input').fill('张三'); + await page.getByTestId('phone-input').fill('13800138000'); + await page.getByTestId('email-input').fill('test@example.com'); + await page.getByTestId('subject-input').fill('咨询业务'); + await page.getByTestId('message-input').fill('我想咨询贵公司的服务详情,请尽快联系我。'); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('消息已发送')).toBeVisible(); + }); + + test.skip('should show loading state during submission', async ({ page }) => { + await page.getByTestId('name-input').fill('张三'); + await page.getByTestId('phone-input').fill('13800138000'); + await page.getByTestId('email-input').fill('test@example.com'); + await page.getByTestId('subject-input').fill('咨询业务'); + await page.getByTestId('message-input').fill('我想咨询贵公司的服务详情,请尽快联系我。'); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('发送中...')).toBeVisible(); + }); + + test.skip('should reset form after successful submission', async ({ page }) => { + await page.getByTestId('name-input').fill('张三'); + await page.getByTestId('phone-input').fill('13800138000'); + await page.getByTestId('email-input').fill('test@example.com'); + await page.getByTestId('subject-input').fill('咨询业务'); + await page.getByTestId('message-input').fill('我想咨询贵公司的服务详情,请尽快联系我。'); + + await page.getByTestId('submit-button').click(); + + await expect(page.getByText('消息已发送')).toBeVisible(); + }); + }); + + test.describe('Security Features', () => { + test.skip('should have CSRF token', async ({ page }) => { + const csrfToken = await page.locator('input[name="_csrf"]').inputValue(); + expect(csrfToken).toBeTruthy(); + expect(csrfToken.length).toBeGreaterThan(0); + }); + + test('should have honeypot field', async ({ page }) => { + const honeypot = page.locator('input[name="website"]'); + await expect(honeypot).toHaveAttribute('style', /display:\s*none/); + await expect(honeypot).toHaveAttribute('tabIndex', '-1'); + }); + + test('should sanitize input on change', async ({ page }) => { + const xssPayload = ''; + + await page.getByTestId('name-input').fill(xssPayload); + const nameValue = await page.getByTestId('name-input').inputValue(); + + expect(nameValue).not.toContain('