Files
cms-website/packages/adapter-wordpress/test/wxr-stream.test.ts
T

462 lines
19 KiB
TypeScript
Raw Normal View History

import { createReadStream } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PassThrough, Readable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import {
parseWxrStream,
WxrError,
WxrLimitError,
WxrParseError,
type WxrEvent,
type WxrItem,
} from '../src/parse/wxr-stream.js'
// mini.xml(Plan 1 夹具):5 item(post 101 / product 111 / page 121 / attachment 201,202)
// + 2 term(31 category / 32 product_cat);term 块在第三个 item 之后、attachment 之前。
const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url))
/** 整串一次性喂入(单 chunk 路径) */
function fromString(xml: string): Readable {
const s = new PassThrough()
s.end(xml)
return s
}
/** 按字节切块喂入(跨 chunk 的 UTF-8 多字节与 token 边界路径;7 字节必拆中文与 ]]>) */
function fromStringChunked(xml: string, size = 7): Readable {
return Readable.from(
(function* () {
const buf = Buffer.from(xml, 'utf8')
for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size)
})(),
)
}
async function collect(gen: AsyncGenerator<WxrEvent>): Promise<WxrEvent[]> {
const out: WxrEvent[] = []
for await (const ev of gen) out.push(ev)
return out
}
/** 捕获 reject 的错误实例(而非仅匹配),以便断言 kind / line / column 细节(照 php-unserialize 测试先例) */
async function captureRejection(p: Promise<unknown>): Promise<unknown> {
try {
await p
} catch (e) {
return e
}
throw new Error('期望抛出但未抛出')
}
/** 迭代到错误或完结:同时保留已交付事件与错误(审查 Fix 2 的交付确定性断言用) */
async function collectUntilError(gen: AsyncGenerator<WxrEvent>): Promise<{ events: WxrEvent[]; error: unknown }> {
const events: WxrEvent[] = []
try {
for await (const ev of gen) events.push(ev)
return { events, error: null }
} catch (e) {
return { events, error: e }
}
}
function itemsOf(events: WxrEvent[]): WxrItem[] {
return events
.filter((ev): ev is Extract<WxrEvent, { kind: 'item' }> => ev.kind === 'item')
.map((ev) => ev.item)
}
/** 概要签名:item:<postId>:<postType> / term:<termId>:<taxonomy>(按文档序) */
function signature(events: WxrEvent[]): string[] {
return events.map((ev) =>
ev.kind === 'item'
? `item:${ev.item.postId}:${ev.item.postType}`
: `term:${ev.term.termId}:${ev.term.taxonomy}`,
)
}
describe('parseWxrStream:mini.xml 流式产出', () => {
it('产出顺序与文档一致:post → product → page → term → term → attachment → attachment', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
expect(signature(events)).toStrictEqual([
'item:101:post',
'item:111:product',
'item:121:page',
'term:31:category',
'term:32:product_cat',
'item:201:attachment',
'item:202:attachment',
])
})
it('跨 chunk 切块喂入(7 字节/块)产出与单 chunk 完全一致', async () => {
const { readFileSync } = await import('node:fs')
const xml = readFileSync(FIXTURE, 'utf8')
const events = await collect(parseWxrStream(fromStringChunked(xml)))
expect(signature(events)).toStrictEqual([
'item:101:post',
'item:111:product',
'item:121:page',
'term:31:category',
'term:32:product_cat',
'item:201:attachment',
'item:202:attachment',
])
})
it('item 事件字段完整:post 101 逐一断言具体值', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 101)
expect(item).toBeDefined()
expect(item).toMatchObject({
postId: 101,
postType: 'post',
status: 'publish',
postName: 'fixture-news-a',
link: 'https://fixture.example/news/fixture-news-a.html',
title: 'Fixture News A',
contentEncoded:
'<p>Body with <a href="/products/fixture-product/">link</a> and <img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png"/></p>',
excerptEncoded: 'Fixture excerpt A',
postDate: '2026-10-05 09:00:00',
postModified: '2026-10-06 10:00:00',
attachmentUrl: '',
creator: 'editor',
})
})
it('attachment item:attachmentUrl 与 inherit 状态', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const a201 = itemsOf(events).find((it) => it.postId === 201)
expect(a201).toMatchObject({
postType: 'attachment',
status: 'inherit',
attachmentUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png',
})
})
it('term 事件字段:term_id/taxonomy/slug/name(term 31)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const term = events.find((ev) => ev.kind === 'term' && ev.term.termId === 31)
expect(term).toBeDefined()
expect(term?.kind === 'term' && term.term).toMatchObject({
termId: 31,
taxonomy: 'category',
slug: 'company',
name: 'Company',
})
})
it('postmeta 聚合:post 101 恰 3 条(_thumbnail_id + Yoast title/metadesc)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 101)
expect(item?.metas).toStrictEqual([
{ key: '_thumbnail_id', value: '201' },
{ key: '_yoast_wpseo_title', value: 'Fixture SEO Title A' },
{ key: '_yoast_wpseo_metadesc', value: 'Fixture SEO description A.' },
])
})
it('postmeta 聚合:product 111 恰 3 条(gallery 与 wppop_sections 原样字符串)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 111)
expect(item?.metas).toStrictEqual([
{ key: '_thumbnail_id', value: '202' },
{ key: '_product_image_gallery', value: '203,204' },
{
key: 'wppop_sections',
value: 'a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"<p>Tab</p>";}}',
},
])
})
it('category 元素聚合进 item 事件:post 101 → category/company/Company;product 111 无', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const post101 = itemsOf(events).find((it) => it.postId === 101)
expect(post101?.categories).toStrictEqual([{ domain: 'category', nicename: 'company', value: 'Company' }])
const product111 = itemsOf(events).find((it) => it.postId === 111)
expect(product111?.categories).toStrictEqual([])
})
it('rawLocation:wxr://item/<文档内序号从1起>,term 同理', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
expect(itemsOf(events).map((it) => it.rawLocation)).toStrictEqual([
'wxr://item/1',
'wxr://item/2',
'wxr://item/3',
'wxr://item/4',
'wxr://item/5',
])
expect(
events
.filter((ev) => ev.kind === 'term')
.map((ev) => (ev.kind === 'term' ? ev.term.rawLocation : '')),
).toStrictEqual(['wxr://term/1', 'wxr://term/2'])
})
})
// 标题 CDATA 长 16 字符:供 maxCdataLength / maxFieldLength(=10)触发超限
const SMALL_XML = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<item><title><![CDATA[AAAAAAAAAAAAAAAA]]></title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
<item><title><![CDATA[BBBBBBBBBBBBBBBB]]></title><wp:post_id>2</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
<item><title><![CDATA[CCCCCCCCCCCCCCCC]]></title><wp:post_id>3</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
</channel>
</rss>`
describe('parseWxrStream:限额防线(WxrLimitError,kind 区分)', () => {
it('maxItems=2:第 3 个 item 开始前抛 kind items', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxItems: 2 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err).toBeInstanceOf(WxrError)
expect(err.kind).toBe('items')
})
it('maxCdataLength=10:超长 CDATA 抛 kind cdata', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxCdataLength: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('cdata')
// 限额错误 message 附 saxes 行列位置(钉死,审查 Fix 6c)
expect(err.message).toMatch(/line \d+, column \d+/)
})
it('超限抛错前已完成事件确定性交付:单 chunk 与 7 字节切块产出一致(审查 Fix 2)', async () => {
const single = await collectUntilError(
parseWxrStream(fromString(SMALL_XML), { limits: { maxItems: 2 } }),
)
const chunked = await collectUntilError(
parseWxrStream(fromStringChunked(SMALL_XML), { limits: { maxItems: 2 } }),
)
expect(single.error).toBeInstanceOf(WxrLimitError)
expect(chunked.error).toBeInstanceOf(WxrLimitError)
expect(signature(single.events)).toStrictEqual(['item:1:post', 'item:2:post'])
expect(signature(chunked.events)).toStrictEqual(['item:1:post', 'item:2:post'])
})
it('maxFieldLength=10:单字段累计超限抛 kind fieldLength(防单个 title 灌爆内存)', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxFieldLength: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('fieldLength')
})
it('maxDepth=2:rss>channel>item 嵌套超限抛 kind depth', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxDepth: 2 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('depth')
})
it('timeBudgetMs:可注入时钟超预算 → kind time', async () => {
let calls = 0
const now = () => (calls++ === 0 ? 0 : 1_000_000) // 第 1 次取起点,其后均"超预算"
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { timeBudgetMs: 120_000 }, now })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('time')
})
it('maxTermCount=1:第 2 个 term 抛 kind terms', async () => {
const xml = SMALL_XML.replace(
'</channel>',
'<wp:term><wp:term_id>1</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy><wp:term_slug>a</wp:term_slug><wp:term_name>A</wp:term_name></wp:term>' +
'<wp:term><wp:term_id>2</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy><wp:term_slug>b</wp:term_slug><wp:term_name>B</wp:term_name></wp:term></channel>',
)
const err = (await captureRejection(
collect(parseWxrStream(fromString(xml), { limits: { maxTermCount: 1 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('terms')
})
it('maxWxrBytes:累计字节数超限抛 kind bytes', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxWxrBytes: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('bytes')
})
it('maxMetaPerItem:单 item postmeta 数超限抛 kind metaPerItem', async () => {
const xml = SMALL_XML.replace(
'</item>',
'<wp:postmeta><wp:meta_key><![CDATA[k1]]></wp:meta_key><wp:meta_value><![CDATA[v1]]></wp:meta_value></wp:postmeta>' +
'<wp:postmeta><wp:meta_key><![CDATA[k2]]></wp:meta_key><wp:meta_value><![CDATA[v2]]></wp:meta_value></wp:postmeta></item>',
)
const err = (await captureRejection(
collect(parseWxrStream(fromString(xml), { limits: { maxMetaPerItem: 1 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('metaPerItem')
})
})
describe('parseWxrStream:外部实体防线(不解析实体、不注入替换文本、不访问网络)', () => {
// 选定行为(钉死):saxes 6 实体表固定为 XML 五预定义 + 数字字符引用,DOCTYPE
// 内部/SYSTEM 实体声明一律不注册——引用即 "undefined entity." 结构错误(WxrParseError),
// 替换文本绝不进入任何产出字段;saxes 为纯字符串解析器,外部 SYSTEM 标识符从不被抓取。
const SECRET = 'SECRET-REPLACEMENT-TEXT'
async function withTempEntityXml(
doctype: string,
body: string,
run: (path: string) => Promise<void>,
): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'wxr-entity-'))
const path = join(dir, 'entity.xml')
await writeFile(
path,
`<?xml version="1.0" encoding="UTF-8"?>\n${doctype}\n<rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>${body}</channel></rss>`,
'utf8',
)
try {
await run(path)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
it('DOCTYPE 内部 ENTITY:引用处抛 WxrParseError,替换文本不进产出(临时文件 + createReadStream)', async () => {
await withTempEntityXml(
`<!DOCTYPE rss [<!ENTITY xxe "${SECRET}">]>`,
'<item><title>&xxe;</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type></item>',
async (path) => {
const events: WxrEvent[] = []
const err = (await captureRejection(
(async () => {
for await (const ev of parseWxrStream(createReadStream(path))) events.push(ev)
})(),
)) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('undefined entity')
// 无论解析走到哪一步:替换文本绝不出现于任何已产出事件
expect(JSON.stringify(events)).not.toContain(SECRET)
},
)
})
it('SYSTEM 外部实体:同样拒绝(不解析、不抓取——无任何网络 I/O)', async () => {
await withTempEntityXml(
'<!DOCTYPE rss [<!ENTITY ext SYSTEM "file:///etc/passwd">]>',
'<item><title>&ext;</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type></item>',
async (path) => {
const err = (await captureRejection(collect(parseWxrStream(createReadStream(path))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('undefined entity')
},
)
})
it('预定义实体不受影响:&amp; / 数字字符引用正常解析', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>plain</title><wp:post_name>a&amp;b&#65;</wp:post_name><wp:post_id>7</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.postName).toBe('a&bA')
})
})
describe('parseWxrStream:畸形 XML → WxrParseError(带 saxes 行列位置)', () => {
it('提前闭合:精确 line/column(4:17)与原始信息', async () => {
const xml =
'<?xml version="1.0"?>\n<rss><channel>\n<item><title>a</title></item>\n</channel></item></rss>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err).toBeInstanceOf(WxrError)
expect(err.line).toBe(4)
expect(err.column).toBe(17)
expect(err.message).toContain('unexpected close tag')
})
it('未闭合(截断)文件:close 阶段报错,行列为有效正数', async () => {
const xml = '<?xml version="1.0"?>\n<rss><channel>\n<item><title>a</title></item>\n</channel>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.line).toBeGreaterThanOrEqual(1)
expect(err.column).toBeGreaterThanOrEqual(0)
expect(err.message).toContain('unclosed')
})
})
describe('parseWxrStream:wp:post_id / wp:term_id 严格整数解析(审查 Fix 3)', () => {
it.each([
['', null],
['0x10', null],
['12abc', null],
['-7', -7],
['99999999999999999999', null],
['101', 101],
])('post_id 输入 %j → %j', async (raw, expected) => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
`<item><title>t</title><wp:post_id>${raw}</wp:post_id><wp:post_type>post</wp:post_type></item>` +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.postId).toBe(expected)
})
it('term_id 同一规则:非纯数字 → null', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<wp:term><wp:term_id>0x10</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy></wp:term>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(events[0]?.kind === 'term' && events[0].term.termId).toBeNull()
})
})
describe('parseWxrStream:嵌套畸形 fail-fast(审查 Fix 4)', () => {
it('item 内嵌 item:立即 WxrParseError,外层不被截断产出', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>outer</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type>' +
'<item><wp:post_id>2</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</item></channel></rss>'
const { events, error } = await collectUntilError(parseWxrStream(fromString(xml)))
expect(error).toBeInstanceOf(WxrParseError)
expect((error as WxrParseError).message).toContain('嵌套')
expect(events).toStrictEqual([]) // 嵌套发生在 item 中途——错误前无已完成事件
})
it('wp:term 内嵌 wp:term:同样 fail-fast', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<wp:term><wp:term_id>1</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy>' +
'<wp:term><wp:term_id>2</wp:term_id></wp:term>' +
'</wp:term></channel></rss>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('嵌套')
})
})
describe('parseWxrStream:流契约钉死(审查 Fix 6)', () => {
it('消费方提前 break:上游流被销毁(finally 兜底契约)', async () => {
const stream = fromString(SMALL_XML)
for await (const _ev of parseWxrStream(stream)) {
break // 收到首个事件即退出
}
expect(stream.destroyed).toBe(true)
})
it('text 与 CDATA 混合内容累计为同一字段值', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>pre<![CDATA[mid]]>post</title><wp:post_id>5</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.title).toBe('premidpost')
})
})