// 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' /** 本文件位于 /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; 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 } }).$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((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() } }) })