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: '
正常内容
',
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)
})
}
})