// 导入报告 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 = '' const MALICIOUS_ESCAPED = '<script src="https://evil.example/x.js"></script>' const ATTR_ESCAPE = `">` const ATTR_ESCAPE_ESCAPED = '"><img onerror=alert(1) src=x>' function baseReport(overrides: Partial = {}): 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(']*\son\w+=/i) }) it('概览/计数/路径/版本块结构:计数表键字典序、快照缺席呈现(无)', () => { const html = renderImportReportHtml(baseReport()) expect(html).toContain('

导入报告

') 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('

计数

'), html.indexOf('')) 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('

(无)

') // adapter 空组 }) it('分组缺席(注入管道形态)回退 flat 清单;无问题时呈现(无)', () => { const flat = renderImportReportHtml(baseReport({ issues: ['悬空 attachment 引用:2 条'] })) expect(flat).toContain('
  • 悬空 attachment 引用:2 条
  • ') const clean = renderImportReportHtml(baseReport({ status: 'succeeded' })) expect(clean).toContain('

    (无)

    ') 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) }) })