Files
cms-website/tests/unit/import-report-html.test.ts
T
浅念 a7177afde7 feat(import): HTML 报告与错误封顶;orchestrator 抽取
- report-html.ts:renderImportReportHtml(Plan 2 Task 6 模板纪律——全动态文本
  转义/静态骨架无 script/分组固定序渲染/封顶注记),import.html 与 import.json
  同 try 原子落盘(同 REPORT_WRITE_FAILED 失败口径)
- 报告 issues 封顶(Task 6 推迟项):REPORT_ISSUES_CAP=10k——flat 清单与
  issueGroups 各组同限截断 + issuesTruncated 计数;终态判定(succeeded_with_issues)
  在全量长度上做,截断不吞终态事实;counts/sanitize 始终完整
- orchestrator.ts 抽取(审查触发器 import.service 849 行):orchestratedPipeline/
  mergeOutcomes/sumCounts/trySyncFinish 迁出为纯函数 over ctx;契约类型单源
  import.service.ts 不动;既有测试全绿即证零行为变更
- 测试 +8:单元 7(恶意 payload 转义/结构/分组序/封顶注记/渲染不改输入)+
  集成 1(10_003 条注入:截断计数/JSON 原文保留/HTML 转义/终态判定);333 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 21:20:12 +08:00

125 lines
6.1 KiB
TypeScript
Raw 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.
// 导入报告 HTML 渲染测试(Plan 3 Task 8——Task 6 推迟项兑现):钉 Plan 2 Task 6 模板
// 纪律的导入侧镜像——①恶意 payload 全转义(issue/error/键名等所有动态文本通道,
// 报告不得成为存储型 XSS 载体)②结构(分组视图按固定序渲染、封顶注记、错误块)
// ③对象层/渲染层分离(renderImportReportHtml 不改输入——对象层保留原文)。
import { describe, expect, it } from 'vitest'
import type { ImportReport } from '../../apps/api/src/modules/import/import.service.js'
import { renderImportReportHtml } from '../../apps/api/src/modules/import/report-html.js'
const MALICIOUS = '<script src="https://evil.example/x.js"></script>'
const MALICIOUS_ESCAPED = '&lt;script src=&quot;https://evil.example/x.js&quot;&gt;&lt;/script&gt;'
const ATTR_ESCAPE = `"><img onerror=alert(1) src=x>`
const ATTR_ESCAPE_ESCAPED = '&quot;&gt;&lt;img onerror=alert(1) src=x&gt;'
function baseReport(overrides: Partial<ImportReport> = {}): ImportReport {
return {
jobId: 'job-0001',
generatedAt: '2026-10-21T00:00:00.000Z',
status: 'succeeded_with_issues',
siteKey: 'demo',
siteId: '11111111-1111-1111-1111-111111111111',
connectionId: '22222222-2222-2222-2222-222222222222',
idempotencyKey: 'import:demo:snap:v1/v1/v1',
attempt: 1,
snapshots: {
wxr: { id: '33333333-3333-3333-3333-333333333333', kind: 'wxr', sha256: 'a'.repeat(64), byteSize: 1024, storagePath: 'incoming/a.xml' },
media_bundle: null,
manual_url_list: null,
},
versions: { adapter: 'a1', mapping: 'm1', sanitizer: 's1', parser: 'p1' },
paths: { productsPath: '/products/', newsPath: '/news/' },
counts: { contentEntries: 3, termsProcessed: 2 },
issues: [],
issuesTruncated: 0,
error: null,
durationMs: 1234.5,
...overrides,
}
}
describe('renderImportReportHtml(Task 8:转义/结构/封顶注记)', () => {
it('所有动态文本通道全转义:issue/错误 summary/快照 id 与 sha——恶意 payload 不以原文出现', () => {
const report = baseReport({
issues: [MALICIOUS, ATTR_ESCAPE],
issueGroups: { content: [MALICIOUS], adapter: [ATTR_ESCAPE] },
error: { code: 'PIPELINE_FAILED', summary: `[pipeline] Error: ${MALICIOUS}` },
})
const html = renderImportReportHtml(report)
// 转义形出现在分组视图与错误块(<>&"' 全集)
expect(html).toContain(MALICIOUS_ESCAPED)
expect(html).toContain(ATTR_ESCAPE_ESCAPED)
expect(html).toContain(`[pipeline] Error: ${MALICIOUS_ESCAPED}`)
// 原文 payload 零出现(骨架无 script 标签——出现即 XSS 面)
expect(html).not.toContain('<script')
expect(html).not.toContain(MALICIOUS)
expect(html).not.toContain('<img') // 未转义标签形态(转义后是 &lt;img)
// 静态骨架纪律:真实标签上无事件属性(转义文本中的 onerror= 字样无害——标签语法已破)
expect(html).not.toMatch(/<[a-z][^>]*\son\w+=/i)
})
it('概览/计数/路径/版本块结构:计数表键字典序、快照缺席呈现(无)', () => {
const html = renderImportReportHtml(baseReport())
expect(html).toContain('<h2>导入报告</h2>')
expect(html).toContain('状态: succeeded_with_issues')
expect(html).toContain('media_bundle:(无)')
expect(html).toContain('manual_url_list:(无)')
// 计数表键字典序(contentEntries < termsProcessed)
const table = html.slice(html.indexOf('<h3>计数</h3>'), html.indexOf('</table>'))
expect(table.indexOf('contentEntries')).toBeLessThan(table.indexOf('termsProcessed'))
expect(html).toContain('productsPath: /products/')
expect(html).toContain('adapter: a1')
})
it('分组视图按固定序渲染(content→term→media→route→adapter→orchestrator),组内条数入标题', () => {
const html = renderImportReportHtml(
baseReport({
issues: ['a1', 'r1'],
issueGroups: { route: ['r1'], content: ['a1'], adapter: [] },
}),
)
expect(html).toContain('内容(content-writer)(1 条)')
expect(html).toContain('路由(route-writer)(1 条)')
expect(html).toContain('适配器(行级/unsupported/error)(0 条)')
expect(html.indexOf('内容(content-writer)')).toBeLessThan(html.indexOf('路由(route-writer)'))
expect(html.indexOf('路由(route-writer)')).toBeLessThan(html.indexOf('适配器(行级/unsupported/error)'))
expect(html).toContain('<p>(无)</p>') // adapter 空组
})
it('分组缺席(注入管道形态)回退 flat 清单;无问题时呈现(无)', () => {
const flat = renderImportReportHtml(baseReport({ issues: ['悬空 attachment 引用:2 条'] }))
expect(flat).toContain('<li>悬空 attachment 引用:2 条</li>')
const clean = renderImportReportHtml(baseReport({ status: 'succeeded' }))
expect(clean).toContain('<p>(无)</p>')
expect(clean).not.toContain('另有')
})
it('封顶注记:issuesTruncated>0 时呈现溢出条数(仅计数)', () => {
const html = renderImportReportHtml(
baseReport({ issues: ['i1'], issuesTruncated: 10_003 }),
)
expect(html).toContain('(另有 10003 条超出固化上限,仅计数)')
})
it('清洗统计与适配器分段计时块(出席才渲染)', () => {
const withBlocks = renderImportReportHtml(
baseReport({
sanitize: { changedRows: 7, byDiffType: { 'removed-tag': 5, 'removed-attr': 2 } },
adapterTiming: { parseMs: 11.2, sanitizeMs: 3.4 },
}),
)
expect(withBlocks).toContain('changed rows: 7')
expect(withBlocks).toContain('removed-attr=2、removed-tag=5') // byDiffType 键也字典序
expect(withBlocks).toContain('adapter parse: 11.200ms')
const without = renderImportReportHtml(baseReport())
expect(without).not.toContain('清洗统计')
expect(without).not.toContain('adapter parse')
})
it('渲染不改输入对象(对象层保留原文——JSON 归档语义层不动)', () => {
const report = baseReport({ issues: [MALICIOUS] })
renderImportReportHtml(report)
expect(report.issues[0]).toBe(MALICIOUS)
})
})