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:
浅念
2026-10-21 21:20:12 +08:00
parent 3776a0d6ad
commit a7177afde7
5 changed files with 622 additions and 198 deletions
+44
View File
@@ -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('&lt;script src=&quot;https://evil.example/x.js&quot;&gt;')
expect(html).toContain('(另有 3 条超出固化上限,仅计数)')
expect(html).toContain('悬空 attachment 引用:0')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})