Files
cms-website/tests/integration/preview-server.test.ts
T
浅念 7194d1bcef feat(preview): 路由驱动的本地预览服务器
Plan 4 Task 7(V2 spec §12.2):apps/preview-server 零依赖 node:http 服务——
只读 succeeded job 的 dist + route-manifest(不查库),exact/ignore 双索引匹配
(query_key 行全串优先),html/asset 文件服务(manifest content_type 优先、
扩展名 MIME 表兜底、未知扩展 octet-stream)、redirect 301+Location、gone 410、
未命中 404 走 dist/404.html 自定义页;全响应 X-Robots-Tag noindex + HTML 响应
基础 CSP;回环绑定白名单(库级即拒);穿越预检先于 URL 解析(裸/编码点段、
反斜杠)+ target_file resolve+realpath 双检在 dist 内;目录请求一律 404(禁
列表);仅 GET/HEAD。sf.ts 增 preview:start(job 定位 + succeeded 校验 + 产物
行读取后即断池;--host 外值拒绝先于 DB 访问)。集成测试 12 例:真实构建产物
全链 + 手构 gone/MIME 探针行 + CLI 拒绝与子进程 happy path 冒烟。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:29:30 +08:00

394 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Plan 4 Task 7 失败测试(先于实现):路由驱动的本地预览服务器(V2 spec §12.2)。
// 夹具策略:先跑一次 createBuildJob 全链(真实构建产物 = mini 夹具站点 succeeded job),
// 产物 dist + route-manifest 复用于全部用例;manifest 是 createPreviewServer 的独立入参,
// 故 gone 行与扩展名映射探针行直接手构注入(真实数据暂无 kind=gone——§12.1 契约先行)。
// 覆盖契约(对照计划 Task 7 十条):
// 1. html 行 200 + Content-Type text/html; charset=utf-8(body = dist 产物逐字节)
// 2. redirect 行 301 + Location(/?attachment_id=201 查询形态——exact_key 含查询串)
// 3. asset 行 200 + 正确 MIME(png 真实行 content_type;jpg/webp/pdf 扩展名映射;
// 未知扩展 → application/octet-stream)
// 4. 未命中 404 + dist/404.html 自定义页(Content-Type html)
// 5. gone 行 410 + 简短 HTML 说明(手构 manifest 行)
// 6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应基础 CSP(default-src 'self')
// 7. 绑定 127.0.0.1(server.address() 断言)+ host 外值拒绝(库级 + CLI 级 spawn)
// 8. 路径穿越拒绝:/../、%2e%2e%2f、..%5c、反斜杠 → 400/404 且不读盘外文件
// 9. 目录列表禁用(dist 子目录请求 → 404,不出文件清单)
// 10. HEAD 方法支持(200 同头无 body)
// 11. CLI:--host 外值拒绝(校验先于 DB 访问——tsx 直跑 sf.ts)
// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停)
import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { request } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { RouteManifest, RouteManifestRoute } from '@sf/contracts'
import { createBuildJob } from '../../apps/api/src/modules/build/build.service.js'
import { createImportJob } from '../../apps/api/src/modules/import/import.service.js'
import { publishSiteContent } from '../../apps/api/src/modules/publish/publish.service.js'
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
import { getDb } from '../../apps/api/src/db/client.js'
import { rand, withImmutableCleanup, type Db } from './helpers.js'
import { createPreviewServer, type PreviewServerHandle } from '../../apps/preview-server/server.mjs'
/** 本文件位于 <repo>/tests/integration/ → 上两级是仓库根 */
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml')
const TSX_CLI = join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs')
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
function mediaBytes(seed: string): Buffer {
return Buffer.concat([PNG_SIG, Buffer.from(`media-body:${seed}`, 'latin1')])
}
function sha256of(buf: Buffer | string): string {
return createHash('sha256').update(buf).digest('hex')
}
interface Work {
work: string
storageRoot: string
allowedRoots: string[]
pngA: Buffer
}
async function makeWork(prefix: string): Promise<Work> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
const mediaDir = join(work, 'media')
await mkdir(join(mediaDir, '2026', '10'), { recursive: true })
const pngA = mediaBytes(`preview-a-${rand()}`)
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), pngA)
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), mediaBytes(`preview-b-${rand()}`))
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work], pngA }
}
/** mini 全链(register wxr+media → import → publish):3 条 published(build-pipeline 同款) */
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
const key = `sfpreview-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
mediaDir: join(w.work, 'media'),
allowedRoots: w.allowedRoots,
storageRoot: w.storageRoot,
reportRoot: join(w.work, 'artifacts', 'register'),
})
const result = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') })
expect(result.status).toBe('succeeded_with_issues')
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
expect(run.publishedCount).toBe(3)
return { key, siteId: reg.siteId }
}
/** 本文件写入的行清场(build-pipeline 同款反序:validation 域先行 + 构建域 + 全链) */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from validation_issues where validation_run_id in
(select id from validation_runs where site_id = '${siteId}')`)
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
await db.execute(`delete from build_artifacts where build_job_id in
(select id from build_jobs where site_id = '${siteId}')`)
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from build_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`)
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
await db.execute(`delete from terms where site_id = '${siteId}'`)
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
await db.execute(`delete from source_records where site_id = '${siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
await db.execute(`delete from sites where id = '${siteId}'`)
}
/** 原样路径请求(fetch/URL 会规范化 '/../' 等点段——穿越用例必须走 node:http 裸发) */
function rawRequest(
port: number,
rawPath: string,
method: 'GET' | 'HEAD' = 'GET',
): Promise<{ status: number | undefined; headers: Record<string, string | string[] | undefined>; body: Buffer }> {
return new Promise((resolveP, rejectP) => {
const req = request({ host: '127.0.0.1', port, method, path: rawPath }, (res) => {
const chunks: Buffer[] = []
res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => resolveP({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
})
req.on('error', rejectP)
req.end()
})
}
describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () => {
let db: Db | undefined
let work: Work | undefined
let siteId = ''
let distDir = ''
let manifest: RouteManifest | undefined
let preview: PreviewServerHandle | undefined
let port = 0
beforeAll(async () => {
db = getDb()
work = await makeWork('sf-preview')
const seed = await seedPublishedSite(db, work)
siteId = seed.siteId
const result = await createBuildJob(db, {
siteKey: seed.key,
artifactsRoot: join(work.work, 'artifacts'),
})
expect(result.status).toBe('succeeded')
expect(result.artifacts).toBeDefined()
distDir = result.artifacts!.distPath
manifest = JSON.parse(await readFile(result.artifacts!.routeManifestPath, 'utf8')) as RouteManifest
// 扩展名映射探针文件(真实构建产物外补——jpg/webp/pdf/未知扩展;行不带 content_type,
// 迫使服务端走扩展名→MIME 表)+ gone 行(真实数据暂无,§12.1 契约先行手构)
const probes: Array<{ file: string; route: string }> = [
{ file: 'mimeprobe/photo.jpg', route: '/mimeprobe/photo.jpg' },
{ file: 'mimeprobe/graph.webp', route: '/mimeprobe/graph.webp' },
{ file: 'mimeprobe/doc.pdf', route: '/mimeprobe/doc.pdf' },
{ file: 'mimeprobe/blob.xyz', route: '/mimeprobe/blob.xyz' },
]
for (const p of probes) {
await mkdir(dirname(join(distDir, p.file)), { recursive: true })
await writeFile(join(distDir, p.file), `probe-body:${p.route}\n`, 'utf8')
}
const probeRows: RouteManifestRoute[] = probes.map((p) => ({
exact_key: p.route,
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: p.file,
status: 200,
indexable: false,
}))
const goneRow: RouteManifestRoute = {
exact_key: '/gone-page/',
query_policy: 'ignore',
query_key: null,
kind: 'gone',
status: 410,
indexable: false,
}
const extended: RouteManifest = { ...manifest!, routes: [...manifest!.routes, ...probeRows, goneRow] }
preview = createPreviewServer({ distDir, routeManifest: extended, host: '127.0.0.1', port: 0 })
const addr = await preview.start()
expect(addr.address).toBe('127.0.0.1')
port = addr.port
}, 300_000)
afterAll(async () => {
await preview?.stop()
if (db !== undefined) {
try {
if (siteId !== '') await cleanupSiteRows(db, siteId)
} finally {
await (db as { $client: { end: () => Promise<void> } }).$client.end()
}
}
if (work !== undefined) await rm(work.work, { recursive: true, force: true })
})
async function get(p: string): Promise<{ status: number; headers: Headers; text: string; bytes: Buffer }> {
const res = await fetch(new URL(p, `http://127.0.0.1:${port}/`))
const ab = await res.arrayBuffer()
return {
status: res.status,
headers: res.headers,
text: new TextDecoder().decode(ab),
bytes: Buffer.from(ab),
}
}
it('1. html 行 200 + Content-Type text/html; charset=utf-8(body 逐字节 = dist 产物)', async () => {
const res = await get('/')
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
expect(res.bytes).toStrictEqual(await readFile(join(distDir, 'index.html')))
})
it('2. redirect 行 301 + Location(?attachment_id=201 查询形态命中 query_key 行)', async () => {
const res = await fetch(new URL('/?attachment_id=201', `http://127.0.0.1:${port}/`), {
redirect: 'manual',
})
expect(res.status).toBe(301)
expect(res.headers.get('location')).toBe('/wp-content/uploads/2026/10/fixture-a.png')
})
it('3. asset 行 200 + MIME:png 真实行 content_type;jpg/webp/pdf 扩展名映射;未知扩展 octet-stream', async () => {
const png = await get('/wp-content/uploads/2026/10/fixture-a.png')
expect(png.status).toBe(200)
expect(png.headers.get('content-type')).toBe('image/png')
expect(sha256of(png.bytes)).toBe(sha256of(work!.pngA))
const jpg = await get('/mimeprobe/photo.jpg')
expect(jpg.status).toBe(200)
expect(jpg.headers.get('content-type')).toBe('image/jpeg')
const webp = await get('/mimeprobe/graph.webp')
expect(webp.status).toBe(200)
expect(webp.headers.get('content-type')).toBe('image/webp')
const pdf = await get('/mimeprobe/doc.pdf')
expect(pdf.status).toBe(200)
expect(pdf.headers.get('content-type')).toBe('application/pdf')
const xyz = await get('/mimeprobe/blob.xyz')
expect(xyz.status).toBe(200)
expect(xyz.headers.get('content-type')).toBe('application/octet-stream')
})
it('4. 未命中 404 + dist/404.html 自定义页(Content-Type html)', async () => {
const res = await get('/definitely-not-a-route/')
expect(res.status).toBe(404)
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
expect(res.text).toStrictEqual(await readFile(join(distDir, '404.html'), 'utf8'))
})
it('5. gone 行 410 + 简短 HTML 说明(手构 manifest 行)', async () => {
const res = await get('/gone-page/')
expect(res.status).toBe(410)
expect(res.headers.get('content-type')?.startsWith('text/html')).toBe(true)
expect(res.text).toContain('410')
expect(res.text.length).toBeLessThan(1024)
})
it('6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应 CSP default-src self(asset 无 CSP)', async () => {
for (const p of ['/', '/definitely-not-a-route/', '/gone-page/', '/wp-content/uploads/2026/10/fixture-a.png']) {
const res = await get(p)
expect(res.headers.get('x-robots-tag'), p).toBe('noindex, nofollow')
}
expect((await get('/')).headers.get('content-security-policy')).toBe("default-src 'self'")
expect((await get('/definitely-not-a-route/')).headers.get('content-security-policy')).toBe("default-src 'self'")
expect((await get('/wp-content/uploads/2026/10/fixture-a.png')).headers.get('content-security-policy')).toBeNull()
})
it('7. 绑定 127.0.0.1(server.address 断言)+ host 外值拒绝(库级同步拒绝)', () => {
expect(port).toBeGreaterThan(0)
expect(
() => createPreviewServer({ distDir, routeManifest: manifest!, host: '0.0.0.0', port: 0 }),
).toThrow(/127\.0\.0\.1|localhost|回环/)
// localhost 是另一合法回环值——构造成功(不 start,无监听副作用)
expect(
createPreviewServer({ distDir, routeManifest: manifest!, host: 'localhost', port: 0 }),
).toBeTypeOf('object')
})
it('8. 路径穿越拒绝:/../、%2e%2e%2f、..%5c、反斜杠 → 400/404,不读盘外', async () => {
const attacks = [
'/../',
'/..',
'/%2e%2e%2f',
'/%2E%2E%2F',
'/..%5c',
'/%2e%2e%5c',
'/\\',
'/\\..\\..',
'/wp-content/uploads/2016/../../../route-manifest.json',
'/%2e%2e/route-manifest.json',
]
for (const a of attacks) {
const res = await rawRequest(port, a)
expect([400, 404], `攻击路径 ${a} 必须被拒绝(不读盘外)`).toContain(res.status)
// 不读盘外:响应体绝不包含 route-manifest.json 的内容标记(dist 上一级的文件)
expect(res.body.toString('utf8')).not.toContain('generated_from_route_snapshot_hash')
}
})
it('9. 目录列表禁用:dist 子目录请求 → 404 且不出文件清单', async () => {
for (const d of ['/wp-content/uploads/2026/10/', '/wp-content/', '/mimeprobe/']) {
const res = await get(d)
expect(res.status, d).toBe(404)
// 不是目录清单:响应是 404 页(自定义页在场时逐字节等于 404.html),不含目录内文件名
expect(res.text).not.toContain('fixture-a.png')
expect(res.text).not.toContain('fixture-b.png')
}
})
it('10. HEAD 方法支持:200 同头无 body', async () => {
const res = await rawRequest(port, '/', 'HEAD')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('text/html; charset=utf-8')
expect(res.headers['x-robots-tag']).toBe('noindex, nofollow')
expect(res.body.byteLength).toBe(0)
})
it('11. CLI host 外值拒绝(tsx 直跑 sf.ts:校验先于 DB 访问,stderr 单行拒绝)', { timeout: 60_000 }, async () => {
const outcome = await new Promise<{ err: Error | null; stderr: string }>((resolveP) => {
execFile(
process.execPath,
[
TSX_CLI,
join(REPO_ROOT, 'scripts', 'sf.ts'),
'preview:start',
'--build',
'00000000-0000-0000-0000-000000000000',
'--host',
'0.0.0.0',
],
{ cwd: REPO_ROOT },
(err, _stdout, stderr) => resolveP({ err, stderr: String(stderr) }),
)
})
// 非零退出(拒绝)+ 单行错误信息带外值与回环纪律
expect(outcome.err).toBeInstanceOf(Error)
expect(outcome.stderr).toContain('--host')
expect(outcome.stderr).toContain('0.0.0.0')
expect(outcome.stderr).toMatch(/127\.0\.0\.1|localhost|回环/)
})
it('12. CLI happy path:preview:start 真实 job → 监听可服务 → 关停(子进程全链冒烟)', { timeout: 90_000 }, async () => {
expect(manifest).toBeDefined()
const jobId = manifest!.build_job_id
const child = spawn(
process.execPath,
[TSX_CLI, join(REPO_ROOT, 'scripts', 'sf.ts'), 'preview:start', '--build', jobId, '--port', '0'],
{ cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
)
let stdout = ''
child.stdout.on('data', (c: Buffer) => {
stdout += c.toString('utf8')
})
child.stderr.on('data', () => {}) // 排水:避免 stderr 管道塞满吊住子进程
try {
// 轮询等 listening 行(port=0 实际端口从中解析)
let cliPort = 0
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const m = /listening: http:\/\/127\.0\.0\.1:(\d+)\//.exec(stdout)
if (m !== null) {
cliPort = Number(m[1])
break
}
await new Promise((r) => setTimeout(r, 250))
}
expect(cliPort, `CLI 未输出 listening 行(stdout=${stdout})`).toBeGreaterThan(0)
// 真实 http 服务可用:首页 200 + noindex 头(DB 已断池——纯 dist+manifest 服务)
const res = await fetch(`http://127.0.0.1:${cliPort}/`)
expect(res.status).toBe(200)
expect(res.headers.get('x-robots-tag')).toBe('noindex, nofollow')
expect(await res.text()).toStrictEqual(await readFile(join(distDir, 'index.html'), 'utf8'))
} finally {
child.kill()
await new Promise<void>((resolveP) => {
child.once('exit', () => resolveP())
setTimeout(() => {
child.kill('SIGKILL')
resolveP()
}, 10_000).unref()
})
}
})
})