通过 VITE_AUTO_FILL_TEST 环境变量控制,在 .env.test 中启用, 使测试环境构建后登录框也能自动填充测试账号,方便测试人员使用。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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 }) => {
|
||
// 设置一个已过期的 Token(exp 为 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)
|
||
})
|
||
}
|
||
})
|