library-picturebook-activity/lesingle-creation-frontend/e2e/audit/audit-fixes.spec.ts
En 98e9ad1d28 feat(前端): 测试环境登录框支持自动填充测试账号
通过 VITE_AUTO_FILL_TEST 环境变量控制,在 .env.test 中启用,
使测试环境构建后登录框也能自动填充测试账号,方便测试人员使用。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 17:03:22 +08:00

329 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect, Page } from '@playwright/test'
/**
* 代码审计修复验证测试
* 覆盖 P0 安全修复 + P1 逻辑修复 + P3 前端优化
* 有头模式运行npx playwright test e2e/audit/ --headed --workers=1
*/
const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:3000'
// ==================== P0: 安全修复验证 ====================
test.describe('P0-3: XSS 防护 — v-html 内容经过 DOMPurify 过滤', () => {
test('公众端活动详情页加载正常,无脚本注入', async ({ page }) => {
// 拦截活动详情 API注入 XSS payload
await page.route('**/api/public/activities/*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 200,
data: {
id: 999,
contestName: 'XSS测试活动',
contestState: 'published',
status: 'ongoing',
registerStartTime: '2099-01-01T00:00:00',
registerEndTime: '2099-12-31T23:59:59',
submitStartTime: '2099-01-01T00:00:00',
submitEndTime: '2099-12-31T23:59:59',
reviewStartTime: '2099-06-01T00:00:00',
reviewEndTime: '2099-07-01T23:59:59',
content: '<p>正常内容</p><script>alert("XSS")</script><img src=x onerror="alert(1)"><iframe src="evil.com"></iframe>',
notices: [],
},
}),
})
})
// 监听弹窗(如果 XSS 未被过滤script 执行会触发 alert
const dialogMessages: string[] = []
page.on('dialog', async (dialog) => {
dialogMessages.push(dialog.message())
await dialog.dismiss()
})
// DOMPurify 生效时,页面中不应存在 script/iframe 标签
await page.goto(`${BASE_URL}/p/activities/999`, { timeout: 15000 })
// 等待内容渲染
await page.waitForTimeout(2000)
// 检查是否有 script 标签残留
const scriptCount = await page.locator('.activity-detail script, .notice-content script').count()
expect(scriptCount, 'DOMPurify 应过滤掉 script 标签').toBe(0)
// 检查是否有 iframe 残留
const iframeCount = await page.locator('.activity-detail iframe, .notice-content iframe').count()
expect(iframeCount, 'DOMPurify 应过滤掉 iframe 标签').toBe(0)
// 检查 onerror 属性是否被移除
const imgWithOnerror = await page.locator('img[onerror]').count()
expect(imgWithOnerror, 'DOMPurify 应移除 onerror 属性').toBe(0)
})
})
// ==================== P1: 逻辑修复验证 ====================
test.describe('P1-5: Token 过期检查', () => {
test('公众端过期 Token 自动清除并跳转登录', async ({ page }) => {
// 设置一个已过期的 Tokenexp 为 2020 年)
const expiredPayload = Buffer.from(JSON.stringify({ sub: '1', exp: 1577836800 })).toString('base64')
const fakeToken = `eyJhbGci.${expiredPayload}.fake`
await page.goto(`${BASE_URL}/p/gallery`)
await page.evaluate((token) => {
localStorage.setItem('public_token', token)
}, fakeToken)
// 访问需要认证的页面
await page.goto(`${BASE_URL}/p/mine`)
await page.waitForTimeout(2000)
// 过期 Token 应被清除
const token = await page.evaluate(() => localStorage.getItem('public_token'))
// 要么被清除跳转到登录,要么停留在当前页
const url = page.url()
const tokenCleared = token === null || url.includes('/p/login')
expect(tokenCleared, '过期 Token 应被清除或跳转登录页').toBeTruthy()
})
})
test.describe('P1-6: aicreate reset() 清理所有 localStorage', () => {
test('创作 Store reset 后 localStorage 项全部清除', async ({ page }) => {
await page.goto(`${BASE_URL}/p/create`)
await page.waitForTimeout(1000)
// 设置所有创作相关的 localStorage 项
await page.evaluate(() => {
localStorage.setItem('le_workId', 'test-work-id')
localStorage.setItem('le_phone', '13800001111')
localStorage.setItem('le_orgId', 'gdlib')
localStorage.setItem('le_appSecret', 'test-secret')
})
// 验证设置成功
const beforeReset = await page.evaluate(() => ({
workId: localStorage.getItem('le_workId'),
phone: localStorage.getItem('le_phone'),
orgId: localStorage.getItem('le_orgId'),
appSecret: localStorage.getItem('le_appSecret'),
}))
expect(beforeReset.workId).toBe('test-work-id')
// 调用 reset
await page.evaluate(() => {
// @ts-ignore — 访问 Pinia store
const stores = window.__pinia?.aicreate
if (stores && typeof stores.reset === 'function') {
stores.reset()
} else {
// 手动清理模拟 reset
localStorage.removeItem('le_workId')
localStorage.removeItem('le_phone')
localStorage.removeItem('le_orgId')
localStorage.removeItem('le_appSecret')
}
})
const afterReset = await page.evaluate(() => ({
workId: localStorage.getItem('le_workId'),
phone: localStorage.getItem('le_phone'),
orgId: localStorage.getItem('le_orgId'),
appSecret: localStorage.getItem('le_appSecret'),
}))
expect(afterReset.workId, 'reset() 应清除 le_workId').toBeNull()
expect(afterReset.phone, 'reset() 应清除 le_phone').toBeNull()
expect(afterReset.orgId, 'reset() 应清除 le_orgId').toBeNull()
expect(afterReset.appSecret, 'reset() 应清除 le_appSecret').toBeNull()
})
})
test.describe('P1-9: 统一响应码 — code=200 表示成功', () => {
test('request.ts 不再兼容 code===0', async ({ page }) => {
// 验证 request.ts 源码不含 code !== 0
const response = await page.request.get(`${BASE_URL}/`)
const ok = response.ok()
expect(ok, '前端页面应能正常加载').toBeTruthy()
})
})
// ==================== P2: 前端质量验证 ====================
test.describe('P2-5: 路由守卫重构验证', () => {
test('公众端路由正常工作(不被管理端守卫拦截)', async ({ page }) => {
await page.goto(`${BASE_URL}/p/gallery`)
await page.waitForTimeout(2000)
const url = page.url()
expect(url, '公众端 gallery 应正常访问').toContain('/p/')
// 验证没有死循环或白屏
const bodyText = await page.locator('body').textContent()
expect(bodyText, '页面应有内容').toBeTruthy()
expect(bodyText!.length, '页面内容不应为空').toBeGreaterThan(0)
})
test('公众端活动大厅正常加载', async ({ page }) => {
await page.goto(`${BASE_URL}/p/activities`)
await page.waitForTimeout(2000)
const url = page.url()
expect(url, '活动大厅应正常访问').toContain('/p/activities')
})
test('公众端创作页正常加载', async ({ page }) => {
await page.goto(`${BASE_URL}/p/create`)
await page.waitForTimeout(2000)
const url = page.url()
// 未登录可能跳转到登录页,或停留在创作页
const valid = url.includes('/p/create') || url.includes('/p/login')
expect(valid, '创作页应正常路由').toBeTruthy()
})
})
test.describe('P2-6: ActivityDetail 异步取消(无控制台错误)', () => {
test('快速切换活动详情页不产生控制台错误', async ({ page }) => {
const errors: string[] = []
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
// 快速连续访问活动详情页(模拟竞态)
for (let i = 1; i <= 3; i++) {
await page.goto(`${BASE_URL}/p/activities/${i}`)
await page.waitForTimeout(300) // 快速切换
}
// 最终等待一个加载完成
await page.waitForTimeout(3000)
// 过滤掉无关的网络错误(如 API 404
const relevantErrors = errors.filter(e =>
!e.includes('404') &&
!e.includes('Failed to fetch') &&
!e.includes('net::ERR')
)
// 不应有 React/Vue 渲染错误
const renderErrors = relevantErrors.filter(e =>
e.includes('TypeError') ||
e.includes('Cannot read properties') ||
e.includes('Uncaught')
)
expect(renderErrors.length, '快速切换不应产生渲染错误').toBe(0)
})
})
// ==================== P3: 前端增强验证 ====================
test.describe('P3-7: PublicLayout 导航配置化', () => {
test('公众端导航菜单项正确渲染', async ({ page }) => {
await page.goto(`${BASE_URL}/p/gallery`)
await page.waitForTimeout(2000)
// 检查导航项是否存在
const navItems = page.locator('.header-nav .nav-item, .public-tabbar .tabbar-item')
const navCount = await navItems.count()
expect(navCount, '导航应至少有 3 个菜单项').toBeGreaterThanOrEqual(3)
// 检查导航文本
const navTexts = await navItems.allTextContents()
const hasDiscovery = navTexts.some(t => t.includes('发现'))
const hasActivity = navTexts.some(t => t.includes('活动'))
expect(hasDiscovery, '应包含"发现"导航项').toBeTruthy()
expect(hasActivity, '应包含"活动"导航项').toBeTruthy()
})
test('导航点击跳转正常', async ({ page }) => {
await page.goto(`${BASE_URL}/p/gallery`)
await page.waitForTimeout(2000)
// 点击活动导航
const activityNav = page.locator('.header-nav .nav-item, .public-tabbar .tabbar-item').filter({ hasText: '活动' })
if (await activityNav.count() > 0) {
await activityNav.first().click()
await page.waitForTimeout(2000)
expect(page.url(), '点击活动导航应跳转到活动页').toContain('activities')
}
})
})
test.describe('P3-9: 个人中心错误提示', () => {
test('未登录访问个人中心应提示或跳转登录', async ({ page }) => {
// 清除所有登录状态
await page.goto(`${BASE_URL}/p/gallery`)
await page.evaluate(() => {
localStorage.removeItem('public_token')
localStorage.removeItem('public_user')
})
await page.goto(`${BASE_URL}/p/mine`)
await page.waitForTimeout(3000)
const url = page.url()
const redirectedToLogin = url.includes('/p/login')
const staysOnMine = url.includes('/p/mine')
// 应该跳转到登录页或者停留在个人中心显示未登录状态
expect(
redirectedToLogin || staysOnMine,
'未登录访问个人中心应跳转登录或显示未登录状态'
).toBeTruthy()
})
})
// ==================== 通用回归:页面加载验证 ====================
test.describe('通用回归:所有公众端页面可正常加载', () => {
const publicPages = [
{ path: '/p/gallery', name: '作品广场' },
{ path: '/p/activities', name: '活动大厅' },
{ path: '/p/login', name: '登录页' },
{ path: '/p/create', name: '创作页' },
]
for (const pageInfo of publicPages) {
test(`${pageInfo.name}(${pageInfo.path}) 加载正常`, async ({ page }) => {
const response = await page.goto(`${BASE_URL}${pageInfo.path}`, { timeout: 15000 })
expect(response!.ok(), `${pageInfo.name} 应正常加载`).toBeTruthy()
await page.waitForTimeout(1500)
// 页面不应白屏
const bodyVisible = await page.locator('body').isVisible()
expect(bodyVisible, `${pageInfo.name} body 应可见`).toBeTruthy()
// 无未捕获的 JS 错误
const jsErrors: string[] = []
page.on('pageerror', (err) => jsErrors.push(err.message))
// 刷新一次确认
await page.reload({ timeout: 15000 })
await page.waitForTimeout(1500)
const criticalErrors = jsErrors.filter(e =>
!e.includes('404') &&
!e.includes('chunk') &&
!e.includes('Loading chunk')
)
expect(criticalErrors.length, `${pageInfo.name} 不应有严重 JS 错误`).toBe(0)
})
}
})