Files
cms-website/tests/integration/preview-server.test.ts
T
浅念 5c2ee7dc5d refactor(test): 三份 helper 迁移共享
Task 6 审查遗留①(schema 再演进防漂移):makeWork / seedPublishedSite /
cleanupSiteRows 三件套自 build-snapshot / build-pipeline / build-gates /
preview-server 四文件(Task 6 审查时三份,Task 7 又落一份)迁
tests/integration/helpers.ts 共享——清场清单(21 语句 FK 反序 + 不可变
触发器开关)此后只改一处。

- makeWork 保持签名;媒体种子载荷改由 prefix 派生(bytes 仍随机唯一,
  魔数校验只看 PNG 签名——行为等价);shared Work 统一携带 pngA/pngB
- seedPublishedSite 增 keyPrefix 参数(各文件沿用原站点 key 派生前缀
  sfbuild/sfpipe/sfgate/sfpreview);succeeded_with_issues 与
  publishedCount=3 断言随迁(gallery 203/204 悬空语义文件无关)
- import 域短清单清场与 publish 域各自形状不同,不共用——保留原处

零行为变更由 408 测试守护(全绿:根 382 + 主题 26);lint/typecheck 五段过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 15:55:43 +08:00

413 lines
19 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 读 → 监听可服务 → 关停;
// --quiet 透传——子进程 stdout 无逐请求访问日志)
// 13. quiet 开关:quiet:true 静默逐请求日志;缺省(verbose)逐请求一行 stdout
import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { request } from 'node:http'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import type { RouteManifest, RouteManifestRoute } from '@sf/contracts'
import { createBuildJob } from '../../apps/api/src/modules/build/build.service.js'
import { getDb } from '../../apps/api/src/db/client.js'
import { cleanupSiteRows, makeWork, seedPublishedSite, type Db, type Work } 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 TSX_CLI = join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs')
function sha256of(buf: Buffer | string): string {
return createHash('sha256').update(buf).digest('hex')
}
/** 原样路径请求(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, 'sfpreview')
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,
}
// Task 8 实证形态:redirect location 含非 ASCII(全角括号/间隔号——真实 hesupipe
// 1746 redirect 全量校验发现原样发头 ERR_INVALID_CHAR 崩 500 / latin1 乱码)
const nonAsciiRedirectRow: RouteManifestRoute = {
exact_key: '/nonascii-redirect/',
query_policy: 'ignore',
query_key: null,
kind: 'redirect',
status: 301,
indexable: false,
location: '/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg',
}
// Task 8 spec 复审修补回归:非 ASCII 文件名资产——盘上/dist 是裸形文件名(文件系统
// 域不解码),manifest exact_key 须是编码线形(管线生成侧归一——浏览器对裸非 ASCII
// 一律百分号编码后发请求)。与上一行 redirect 构成真实链:跟随 Location 的编码形
// 请求必须命中本资产行(2c 断言全链 200)。
const nonAsciiAssetFile = 'wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg'
await mkdir(dirname(join(distDir, nonAsciiAssetFile)), { recursive: true })
await writeFile(join(distDir, nonAsciiAssetFile), 'nonascii-asset-body\n', 'utf8')
const nonAsciiAssetRow: RouteManifestRoute = {
exact_key: '/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg',
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: `/${nonAsciiAssetFile}`,
status: 200,
content_type: 'image/jpeg',
indexable: false,
}
const extended: RouteManifest = {
...manifest!,
routes: [...manifest!.routes, ...probeRows, goneRow, nonAsciiRedirectRow, nonAsciiAssetRow],
}
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('2b. redirect location 含非 ASCII:Location 头按 UTF-8 百分号编码发(不崩 500/不乱码);迁移页内嵌原串', async () => {
const res = await fetch(new URL('/nonascii-redirect/', `http://127.0.0.1:${port}/`), {
redirect: 'manual',
})
expect(res.status).toBe(301)
// 全角( U+FF08 → %EF%BC%88 / )U+FF09 → %EF%BC%89 / · U+00B7 → %C2%B7
expect(res.headers.get('location')).toBe(
'/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg',
)
const text = await res.text()
expect(text).toContain('/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg')
})
it('2c. 非 ASCII redirect → Location 编码形 → 资产终端 200(spec 复审修补回归:exact_key 编码线形命中)', async () => {
const res = await fetch(new URL('/nonascii-redirect/', `http://127.0.0.1:${port}/`), {
redirect: 'manual',
})
expect(res.status).toBe(301)
const location = res.headers.get('location')
expect(location).toBe('/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg')
// 跟随 Location(浏览器/重定向客户端的真实路径——发编码形请求)→ 命中资产行 → 200
const terminal = await get(location!)
expect(terminal.status).toBe(200)
expect(terminal.headers.get('content-type')).toBe('image/jpeg')
expect(terminal.bytes).toStrictEqual(Buffer.from('nonascii-asset-body\n', 'utf8'))
// 裸形直接请求(未编码客户端形态):URL 解析器编码后同键命中——不可 404
const rawForm = await get('/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg')
expect(rawForm.status).toBe(200)
expect(rawForm.bytes).toStrictEqual(Buffer.from('nonascii-asset-body\n', 'utf8'))
})
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(style-src 放开内联——Astro scoped 样式;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'; style-src 'self' 'unsafe-inline'",
)
expect((await get('/definitely-not-a-route/')).headers.get('content-security-policy')).toBe(
"default-src 'self'; style-src 'self' 'unsafe-inline'",
)
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', '--quiet'],
{ 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'))
// --quiet 透传:服务过请求后子进程 stdout 仍无逐请求访问日志行
await new Promise((r) => setTimeout(r, 500))
expect(stdout).not.toMatch(/\[preview\] (GET|HEAD)/)
} finally {
child.kill()
await new Promise<void>((resolveP) => {
child.once('exit', () => resolveP())
setTimeout(() => {
child.kill('SIGKILL')
resolveP()
}, 10_000).unref()
})
}
})
it('13. quiet 开关:quiet:true 静默逐请求日志;缺省 verbose 逐请求一行', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
try {
const previewLines = (): number => logSpy.mock.calls.filter((c) => String(c[0]).includes('[preview] ')).length
// quiet 实例:请求零访问日志
const quietServer = createPreviewServer({ distDir, routeManifest: manifest!, port: 0, quiet: true })
const addr = await quietServer.start()
try {
expect((await fetch(`http://127.0.0.1:${addr.port}/`)).status).toBe(200)
expect(previewLines()).toBe(0)
} finally {
await quietServer.stop()
}
// 缺省 verbose 对照(beforeAll 主服务):请求恰一行访问日志
const before = previewLines()
await get('/')
expect(previewLines()).toBe(before + 1)
} finally {
logSpy.mockRestore()
}
})
})