fix(adapter-wp): 原型键防御与对抗测试;meta 冲突策略统一(单值先者+issue/phpRaw 清理);extras 原型污染防御;config 校验;prebuild 依赖固化

- Fix 1:routes/STATUS_MAP 查键一律 Object.hasOwn 守卫('constructor'/
  'valueOf' 等继承键裸索引命中函数值绕过 undefined 检查)+ 对抗测试
- Fix 2:prebuild 脚本先建 contracts dist(fresh-clone build 不再 TS2307,
  亦防 stale dist 静默编译;已实测 rm -rf dist 后全链成功)
- Fix 3:单值 meta(thumbnail/seo×2)重复→先者胜出+issue(WP 单值语义,
  首个占位即便值非法不被顶替);wppop 重复→最后一次决定终态(成功清
  phpRaw/失败清 wppop_sections,两键互斥);三方策略 JSDoc 冻结
- Fix 4:extras 改 null 原型字典——'__proto__' meta 不再被 setter 静默
  吞掉(测试钉死自有键+JSON 往返);gallery 累积语义明确
- Fix 5:typeRoutes 空字符串路由值 → TypeError fail fast
- 173→184 测试全绿;typecheck/lint/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 10:10:03 +08:00
parent c99bbbb29e
commit 4c4c43f7d9
3 changed files with 218 additions and 7 deletions
@@ -79,6 +79,11 @@ function expectRowInvariants(row: UniversalRow): void {
}
}
/** extras 为 null 原型字典(原型污染防御,审查 Fix 4):展开为普通对象再作整体等值比较 */
function plain(extras: Record<string, unknown>): Record<string, unknown> {
return { ...extras }
}
describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => {
it('post(101):全字段映射(路由/status/locale/slug/seo/taxonomyRefs/featured/knownFields/bodyHtml 原文)', async () => {
const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 101), CONFIG))
@@ -112,7 +117,7 @@ describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => {
creator: 'editor',
})
// 101 的 3 条 meta 全部被分流(thumbnail/yoast×2)——extras 无未识别残余
expect(row.extras).toStrictEqual({})
expect(plain(row.extras)).toStrictEqual({})
expectRowInvariants(row)
})
@@ -281,7 +286,7 @@ describe('mapItem:meta 分流表', () => {
),
)
expect(issues).toStrictEqual([])
expect(row.extras).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' })
expect(plain(row.extras)).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' })
expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '301' }])
})
@@ -348,3 +353,154 @@ describe('mapItem:category 引用形状防线', () => {
expectRowInvariants(row)
})
})
describe('mapItem:原型键防御(审查 Fix 1——裸索引会命中继承属性)', () => {
it("postType 'constructor'(继承键,值为函数非路由)→ unsupported,不被当合法路由命中", () => {
const result = mapItem(makeItem({ postType: 'constructor' }), CONFIG)
expect(result).toStrictEqual({
kind: 'unsupported',
sourceType: 'wp:constructor',
reason: 'post_type 未配置映射',
rawLocation: 'wxr://item/handmade',
})
})
it("status 'valueOf'(继承键,值为函数非字符串)→ 'unknown' + issue", () => {
const { row, issues } = rowOf(mapItem(makeItem({ status: 'valueOf' }), CONFIG))
expect(row.status).toBe('unknown')
expect(issues).toStrictEqual([`status 值未识别:"valueOf",已映射为 'unknown'`])
})
it("typeRoutes 自定义表同样守卫:'toString' 键未被显式配置 → unsupported", () => {
const custom: MapConfig = { locale: 'zh-CN', typeRoutes: { post: 'wp:post' } }
expect(mapItem(makeItem({ postType: 'toString' }), custom).kind).toBe('unsupported')
})
})
describe('mapItem:meta 冲突策略三方统一(审查 Fix 3)', () => {
it('单值键重复(thumbnail/seoTitle/seoDescription)→ 先者胜出 + issue(WP 单值语义)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: '_thumbnail_id', value: '501' },
{ key: '_thumbnail_id', value: '502' },
{ key: '_yoast_wpseo_title', value: 'T1' },
{ key: '_yoast_wpseo_title', value: 'T2' },
{ key: '_yoast_wpseo_metadesc', value: 'D1' },
{ key: '_yoast_wpseo_metadesc', value: 'D2' },
],
}),
CONFIG,
),
)
expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '501' }])
expect(row.seo).toStrictEqual({ title: 'T1', description: 'D1' })
expect(issues).toStrictEqual([
'meta _thumbnail_id 重复,取先者',
'meta _yoast_wpseo_title 重复,取先者',
'meta _yoast_wpseo_metadesc 重复,取先者',
])
})
it('首个单值键即便值非法也占位:后续同键值不顶替(重复即异常,显式报告不猜)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: '_thumbnail_id', value: '' },
{ key: '_thumbnail_id', value: '503' },
],
}),
CONFIG,
),
)
expect(row.attachmentRefs).toStrictEqual([])
expect(issues).toStrictEqual([
'meta _thumbnail_id 值为空,featured 引用已跳过',
'meta _thumbnail_id 重复,取先者',
])
})
it('wppop 先败后成:extras.phpRaw 被清除、终态为成功产物(issues 留痕不回滚)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: 'wppop_sections', value: 'a:1:{i:0;s:5:"ab' }, // 截断 → 失败
{ key: 'wppop_sections', value: 'a:1:{i:0;s:3:"foo";}' }, // 成功
],
}),
CONFIG,
),
)
expect(Object.hasOwn(row.extras, 'phpRaw')).toBe(false)
expect(row.extras.wppop_sections).toStrictEqual(['foo'])
expect(issues).toHaveLength(1) // 首次失败仍在 issue 日志中
})
it('wppop 先成后败:对称清理——终态 phpRaw、无 wppop_sections 残留', () => {
const bad = 'a:1:{i:0;s:5:"ab'
const { row } = rowOf(
mapItem(
makeItem({
metas: [
{ key: 'wppop_sections', value: 'a:1:{i:0;s:3:"foo";}' },
{ key: 'wppop_sections', value: bad },
],
}),
CONFIG,
),
)
expect(Object.hasOwn(row.extras, 'wppop_sections')).toBe(false)
expect(row.extras.phpRaw).toBe(bad)
})
it('gallery 累积语义:两段 gallery meta 的引用按文档序并入(不属单值冲突策略)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: '_product_image_gallery', value: '601' },
{ key: '_product_image_gallery', value: '602,603' },
],
}),
CONFIG,
),
)
expect(row.attachmentRefs).toStrictEqual([
{ role: 'gallery', sourceId: '601' },
{ role: 'gallery', sourceId: '602' },
{ role: 'gallery', sourceId: '603' },
])
expect(issues).toStrictEqual([])
})
})
describe('mapItem:原型污染防御(审查 Fix 4)', () => {
it("meta key '__proto__' 不静默丢失:extras 保留为自有可枚举键(null 原型字典)", () => {
const { row, issues } = rowOf(
mapItem(makeItem({ metas: [{ key: '__proto__', value: 'proto-v' }] }), CONFIG),
)
expect(issues).toStrictEqual([])
expect(Object.hasOwn(row.extras, '__proto__')).toBe(true)
expect(row.extras['__proto__']).toBe('proto-v')
expect(JSON.stringify(row.extras)).toContain('"__proto__":"proto-v"')
expectRowInvariants(row) // 守卫(含 seo/knownFields/extras 对象形状检查)仍通过
})
})
describe('mapItem:config 校验(审查 Fix 5)', () => {
it('typeRoutes 含空字符串路由值 → TypeError(程序员错误,fail fast)', () => {
expect(() => mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: '' } })).toThrow(TypeError)
expect(() =>
mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: 'wp:post', page: '' } }),
).toThrow(/typeRoutes/)
})
it('非空路由值不误伤:合法自定义表正常产出', () => {
expect(() =>
rowOf(mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: 'wp:post' } })),
).not.toThrow()
})
})