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>
This commit is contained in:
@@ -19,6 +19,9 @@
|
||||
// unsupported/error 事件的 reason/issue 归档 adapter 组(Fix 4——此前被丢弃);
|
||||
// 8. 主列表路径解析期防线(Fix 3):--products-path/--news-path 含穿越段 →
|
||||
// INVALID_LIST_PATH 且零 job 行(建行前抛出——建行后再抛会留悬挂 running 行)。
|
||||
// 9. HTML 报告与 issues 封顶(Task 8 兑现 Task 6 推迟项):import.html 落盘且动态文本
|
||||
// 全转义;issues 超上限(10k)→ 报告固化面截断 + issuesTruncated 计数;终态判定
|
||||
// 用全量长度(截断不吞 succeeded_with_issues 事实);对象层(import.json)保留原文。
|
||||
// 运行于 globalSetup 临时库(ADR-015);storage/报告根 os.tmpdir,finally 清理。
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -601,4 +604,45 @@ describe('编排集成 + 导入报告(四家写入器汇流)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('HTML 报告与 issues 封顶(Task 8):import.html 落盘且转义;issues 超上限 → 截断 + issuesTruncated、终态判定用全量长度、JSON 对象层留原文', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const w = await makeWork('sf-full-html')
|
||||
try {
|
||||
const { key, siteId } = await registerWxrOnly(db, w)
|
||||
try {
|
||||
// 恶意 payload(清洗前 excerpt 形态)+ 超上限 issues(10_003 条)
|
||||
const payload = '<script src="https://evil.example/x.js"></script>'
|
||||
const total = 10_003
|
||||
const issues = Array.from({ length: total }, (_, i) =>
|
||||
i === 0 ? `${payload}悬空 attachment 引用:${i}` : `悬空 attachment 引用:${i}`,
|
||||
)
|
||||
const result = await createImportJob(db, {
|
||||
siteKey: key,
|
||||
reportRoot: w.reportRoot,
|
||||
pipeline: async () => ({ counts: { contentEntries: 0 }, issues, issueGroups: { media: issues } }),
|
||||
})
|
||||
// 终态判定在截断前的全量长度上做(截断是报告固化层语义,不吞终态事实)
|
||||
expect(result.status).toBe('succeeded_with_issues')
|
||||
|
||||
const report = await readReport(w, result.jobId)
|
||||
expect(report.issues).toHaveLength(10_000)
|
||||
expect(report.issuesTruncated).toBe(3)
|
||||
expect(report.issueGroups.media).toHaveLength(10_000) // 各组同限截断
|
||||
expect(report.issues[0]).toContain('<script') // JSON 对象层保留原文(转义只在渲染层)
|
||||
|
||||
// HTML:落盘(与 import.json 同目录)+ 全动态文本转义 + 封顶注记
|
||||
const html = await readFile(join(w.reportRoot, result.jobId, 'reports', 'import.html'), 'utf8')
|
||||
expect(html).not.toContain('<script') // 骨架无 script,出现即 XSS 面
|
||||
expect(html).toContain('<script src="https://evil.example/x.js">')
|
||||
expect(html).toContain('(另有 3 条超出固化上限,仅计数)')
|
||||
expect(html).toContain('悬空 attachment 引用:0')
|
||||
} finally {
|
||||
await cleanupSiteRows(db, siteId)
|
||||
}
|
||||
} finally {
|
||||
await rm(w.work, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 导入报告 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 = '<script src="https://evil.example/x.js"></script>'
|
||||
const ATTR_ESCAPE = `"><img onerror=alert(1) src=x>`
|
||||
const ATTR_ESCAPE_ESCAPED = '"><img onerror=alert(1) src=x>'
|
||||
|
||||
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') // 未转义标签形态(转义后是 <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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user