8f2b9825a6
Task 6(Plan 4):validating 接真实门禁——五项产物检查(①内部死链 ②sitemap 一致 ③SEO 基线 ④大小写冲突 ⑤产物完整性),P0 阻断/P2 报告分级;validation_ runs/validation_issues 落库(severity/rule/url/evidence),任一 P0 → job failed (VALIDATION_FAILED + 产物清场),全过 → succeeded。HTML 解析用 htmlparser2 (sanitize-html 既有传递依赖,lock 已解析 12.0.0——apps/api 显式声明复用,零新 包版本),禁正则提标签。报告 json/html 双落盘且 HTML 先写(Plan 3 窄窗口修, html 转义模板)。顺手:build-snapshot 测试 spawn 缩减 7→2(六文件物化/bodyHtml 重写/ADR-010 改 materializeRenderSnapshot 直调;复用两场景并谱共享单次成功构建), 根套件 97.3s→75.8s;两测试文件清场补 validation 域反序(FK 链先行)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
536 lines
26 KiB
TypeScript
536 lines
26 KiB
TypeScript
// Plan 4 Task 6 失败测试(先于实现):最小门禁 + 完整 build 状态机(spec §13.4/§16.1)。
|
||
// 覆盖契约(对照计划 Task 6):
|
||
// 1. 五门禁逐项一过一 fail(纯产物夹具直调 runGates——无 DB 无 spawn):
|
||
// ① internal-links:dist 内 a[href]/img[src] 根相对目标全命中(html 产物或 asset 文件
|
||
// 或 manifest 路由——redirect 落点无文件不算死链);外域/锚点/绝对 origin/协议相对/
|
||
// mailto: 等非根相对形态豁免——注入坏链 → P0 + issue 定位(页面 + href);
|
||
// ② sitemap-consistency:sitemap 条目 = manifest indexable html 集——多页(extra loc)/
|
||
// 删页(缺 loc)→ P0;
|
||
// ③ seo-baseline:每 html 有非空 title/meta description/canonical(manifest 页精确 =
|
||
// origin+自身路径;非 manifest 页(404.html)前缀匹配)/恰一 H1——缺 title/canonical
|
||
// 指错/双 H1 → P0;缺 description → P2(主题契约"有则合规"——报告不阻断);
|
||
// ④ case-collision:dist 全路径 lowercase 无重复(Windows 盘不敏感防线)——NTFS 大小写
|
||
// 不敏感无法物化冲突对,纯函数 findCaseCollisions 直测;
|
||
// ⑤ artifact-completeness:route-manifest html 行 target_file 全存在——缺席 → P0;
|
||
// 2. P0 阻断语义(mini 全链 1 spawn):validating 挂钩注入坏链 → createBuildJob 以
|
||
// VALIDATION_FAILED 拒绝 → job failed + validation_runs FAIL 行 + validation_issues
|
||
// 行(severity/rule/url/evidence)+ dist 清场(failed 无产物语义);
|
||
// 3. 全过语义(mini 全链 1 spawn):真实主题产物过五门禁 → validation_runs PASS 行 +
|
||
// job succeeded + 报告 json/html 双落盘(HTML 先写——build.json 在则 build.html 必在)。
|
||
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { describe, expect, it } from 'vitest'
|
||
import type { RouteManifest } from '@sf/contracts'
|
||
import { createBuildJob, BuildError } from '../../apps/api/src/modules/build/build.service.js'
|
||
import {
|
||
findCaseCollisions,
|
||
runGates,
|
||
GATE_RULE_VERSION,
|
||
type GateIssue,
|
||
} from '../../apps/api/src/modules/build/gates.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 { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
|
||
|
||
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
|
||
const WXR_FIXTURE = join(FIXTURES_ROOT, 'wxr', 'mini.xml')
|
||
|
||
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||
const ORIGIN = 'https://gate-fixture.example'
|
||
|
||
// ---- 纯产物夹具(手构 dist + manifest + sitemap——runGates 直调面) ----
|
||
|
||
/** 合规 html 页(title/desc/canonical/恰一 H1 + 可配链接面) */
|
||
function pageHtml(opts: { canonical: string; links?: string[] }): string {
|
||
const links = (opts.links ?? []).map((l) => ` ${l}`).join('\n')
|
||
return [
|
||
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
|
||
'<title>Fixture Page</title>',
|
||
'<meta name="description" content="fixture description"/>',
|
||
`<link rel="canonical" href="${opts.canonical}"/>`,
|
||
'</head><body>',
|
||
'<h1>Heading One</h1>',
|
||
links,
|
||
'</body></html>',
|
||
'',
|
||
].join('\n')
|
||
}
|
||
|
||
const BASE_LINKS = [
|
||
'<a href="/about/">About</a>',
|
||
'<a href="/old-path/">Redirect</a>',
|
||
'<img src="/wp-content/uploads/2026/10/a.png" alt="a"/>',
|
||
// 豁免面:外域 / 锚点 / 绝对 origin / 协议相对 / mailto / javascript / 相对形态
|
||
'<a href="https://external.example/x">ext</a>',
|
||
`<a href="${ORIGIN}/about/">abs-origin</a>`,
|
||
'<a href="#top">anchor</a>',
|
||
'<a href="//cdn.example/x.png">proto-rel</a>',
|
||
'<a href="mailto:a@b.example">mail</a>',
|
||
'<a href="javascript:void(0)">js</a>',
|
||
'<img src="data:image/png;base64,AAA" alt="data"/>',
|
||
]
|
||
|
||
function baseManifest(): RouteManifest {
|
||
return {
|
||
manifest_version: 1,
|
||
site_id: '00000000-0000-0000-0000-000000000001',
|
||
build_job_id: '00000000-0000-0000-0000-000000000002',
|
||
origin: ORIGIN,
|
||
generated_from_route_snapshot_hash: 'ab'.repeat(32),
|
||
routes: [
|
||
{
|
||
exact_key: '/',
|
||
query_policy: 'ignore',
|
||
query_key: null,
|
||
kind: 'html',
|
||
target_file: 'index.html',
|
||
status: 200,
|
||
content_type: 'text/html',
|
||
indexable: true,
|
||
},
|
||
{
|
||
exact_key: '/about/',
|
||
query_policy: 'ignore',
|
||
query_key: null,
|
||
kind: 'html',
|
||
target_file: 'about/index.html',
|
||
status: 200,
|
||
content_type: 'text/html',
|
||
indexable: true,
|
||
},
|
||
{
|
||
exact_key: '/wp-content/uploads/2026/10/a.png',
|
||
query_policy: 'ignore',
|
||
query_key: null,
|
||
kind: 'asset',
|
||
target_file: '/wp-content/uploads/2026/10/a.png',
|
||
status: 200,
|
||
content_type: 'image/png',
|
||
indexable: false,
|
||
},
|
||
{
|
||
exact_key: '/old-path/',
|
||
query_policy: 'ignore',
|
||
query_key: null,
|
||
kind: 'redirect',
|
||
status: 301,
|
||
location: '/',
|
||
indexable: false,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function baseSitemapLocs(): string[] {
|
||
return [`${ORIGIN}/`, `${ORIGIN}/about/`]
|
||
}
|
||
|
||
function sitemapXml(locs: string[]): string {
|
||
const items = locs.map((l) => ` <url><loc>${l.replaceAll('&', '&')}</loc></url>`)
|
||
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items.join('\n')}\n</urlset>\n`
|
||
}
|
||
|
||
interface GateFixture {
|
||
distDir: string
|
||
sitemapPath: string
|
||
work: string
|
||
}
|
||
|
||
/** 合规基线夹具:index + about + 404(非 manifest 页)+ asset + redirect 落点路由 */
|
||
async function makeGateFixture(prefix: string): Promise<GateFixture> {
|
||
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
|
||
const distDir = join(work, 'dist')
|
||
await mkdir(join(distDir, 'about'), { recursive: true })
|
||
await mkdir(join(distDir, 'wp-content', 'uploads', '2026', '10'), { recursive: true })
|
||
await writeFile(join(distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
|
||
await writeFile(
|
||
join(distDir, 'about', 'index.html'),
|
||
pageHtml({ canonical: `${ORIGIN}/about/`, links: ['<a href="/">Home</a>'] }),
|
||
'utf8',
|
||
)
|
||
// 404.html:非 manifest 页(站点兜底非内容路由)——canonical 前缀匹配(origin 起头)
|
||
await writeFile(
|
||
join(distDir, '404.html'),
|
||
pageHtml({ canonical: `${ORIGIN}/404`, links: ['<a href="/">Home</a>'] }),
|
||
'utf8',
|
||
)
|
||
await writeFile(join(distDir, 'wp-content', 'uploads', '2026', '10', 'a.png'), PNG_SIG)
|
||
const sitemapPath = join(distDir, 'sitemap.xml')
|
||
await writeFile(sitemapPath, sitemapXml(baseSitemapLocs()), 'utf8')
|
||
return { distDir, sitemapPath, work }
|
||
}
|
||
|
||
async function runFixture(
|
||
fx: GateFixture,
|
||
manifest: RouteManifest = baseManifest(),
|
||
): Promise<{ pass: boolean; issues: GateIssue[] }> {
|
||
return runGates({ distDir: fx.distDir, routeManifest: manifest, sitemapPath: fx.sitemapPath, origin: ORIGIN })
|
||
}
|
||
|
||
function rulesOf(issues: GateIssue[]): string[] {
|
||
return [...new Set(issues.map((i) => i.rule))]
|
||
}
|
||
|
||
describe('最小门禁(纯产物夹具直调)', () => {
|
||
it('全过基线:合规产物 → pass + 零 issue(豁免面:外域/锚点/绝对 origin/协议相对/mailto/相对形态)', async () => {
|
||
const fx = await makeGateFixture('sf-gate-base')
|
||
try {
|
||
const r = await runFixture(fx)
|
||
expect(r.pass).toBe(true)
|
||
expect(r.issues).toStrictEqual([])
|
||
} finally {
|
||
await rm(fx.work, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('① internal-links fail:坏 a[href]/img[src] → P0 + 定位(页面 + href);redirect 落点路由不算死链', async () => {
|
||
const fx = await makeGateFixture('sf-gate-link')
|
||
try {
|
||
// 注入坏链页:a 指向不存在页面、img 指向不存在 asset(/old-path/ 是 redirect——命中)
|
||
await writeFile(
|
||
join(fx.distDir, 'about', 'index.html'),
|
||
pageHtml({
|
||
canonical: `${ORIGIN}/about/`,
|
||
links: ['<a href="/missing-page/">bad</a>', '<img src="/wp-content/uploads/no-such.png" alt="bad"/>', '<a href="/old-path/">ok-redirect</a>', '<a href="/missing-page/?ver=2#frag">bad-query</a>'],
|
||
}),
|
||
'utf8',
|
||
)
|
||
const r = await runFixture(fx)
|
||
expect(r.pass).toBe(false)
|
||
const linkIssues = r.issues.filter((i) => i.rule === 'internal-links')
|
||
expect(linkIssues).toHaveLength(3)
|
||
for (const i of linkIssues) {
|
||
expect(i.severity).toBe('P0')
|
||
expect(i.url).toBe('/about/')
|
||
}
|
||
const hrefs = linkIssues.map((i) => i.evidence.href)
|
||
expect(hrefs).toStrictEqual(['/missing-page/', '/wp-content/uploads/no-such.png', '/missing-page/?ver=2#frag'])
|
||
expect(linkIssues[0]!.evidence.page).toBe('about/index.html')
|
||
// 其余门禁不受牵连
|
||
expect(rulesOf(r.issues)).toStrictEqual(['internal-links'])
|
||
} finally {
|
||
await rm(fx.work, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('② sitemap-consistency fail:多页(extra loc)与删页(缺 loc)→ P0(evidence 带 expected/actual 差异)', async () => {
|
||
const fx = await makeGateFixture('sf-gate-sitemap')
|
||
try {
|
||
// 多页:sitemap 多出一条幽灵 loc
|
||
await writeFile(fx.sitemapPath, sitemapXml([...baseSitemapLocs(), `${ORIGIN}/ghost/`]), 'utf8')
|
||
const extra = await runFixture(fx)
|
||
expect(extra.pass).toBe(false)
|
||
const extraIssues = extra.issues.filter((i) => i.rule === 'sitemap-consistency')
|
||
expect(extraIssues).toHaveLength(1)
|
||
expect(extraIssues[0]!.severity).toBe('P0')
|
||
expect(extraIssues[0]!.evidence.extra).toStrictEqual([`${ORIGIN}/ghost/`])
|
||
expect(extraIssues[0]!.evidence.missing).toStrictEqual([])
|
||
|
||
// 删页:sitemap 缺一条 indexable html
|
||
await writeFile(fx.sitemapPath, sitemapXml([`${ORIGIN}/about/`]), 'utf8')
|
||
const missing = await runFixture(fx)
|
||
expect(missing.pass).toBe(false)
|
||
const missIssues = missing.issues.filter((i) => i.rule === 'sitemap-consistency')
|
||
expect(missIssues).toHaveLength(1)
|
||
expect(missIssues[0]!.evidence.missing).toStrictEqual([`${ORIGIN}/`])
|
||
expect(missIssues[0]!.evidence.extra).toStrictEqual([])
|
||
} finally {
|
||
await rm(fx.work, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('③ seo-baseline fail:缺 title/canonical 指错/双 H1 → P0;缺 description → P2 不阻断', async () => {
|
||
const fx = await makeGateFixture('sf-gate-seo')
|
||
try {
|
||
// 缺 title 页(canonical 换行注入保持其余合规)
|
||
const noTitle = pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace('<title>Fixture Page</title>\n', '')
|
||
// 双 H1 + canonical 指错 + 缺 description(一个页面对应一类——分别注入三页更清晰,
|
||
// 这里单页三错以验证同类合并多证据,另以缺 description 单独验证 P2 通道)
|
||
const messy = [
|
||
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
|
||
'<title>Messy</title>',
|
||
`<link rel="canonical" href="${ORIGIN}/wrong-path/"/>`,
|
||
'</head><body>',
|
||
'<h1>First</h1><h1>Second</h1>',
|
||
'</body></html>',
|
||
'',
|
||
].join('\n')
|
||
const noDesc = pageHtml({ canonical: `${ORIGIN}/`, links: [] }).replace(
|
||
'<meta name="description" content="fixture description"/>\n',
|
||
'',
|
||
)
|
||
await writeFile(join(fx.distDir, 'about', 'index.html'), noTitle, 'utf8')
|
||
await writeFile(join(fx.distDir, '404.html'), messy, 'utf8')
|
||
// index.html 换成缺 description(canonical 保持 /)
|
||
await writeFile(join(fx.distDir, 'index.html'), noDesc, 'utf8')
|
||
|
||
const r = await runFixture(fx)
|
||
const seo = r.issues.filter((i) => i.rule === 'seo-baseline')
|
||
// 缺 title(about)+ canonical 指错(404.html 非 manifest 页——前缀匹配通过?否:
|
||
// /wrong-path/ 仍以 origin 起头,前缀匹配放行——故 canonical 指错须用 manifest 页验证)
|
||
expect(seo.some((i) => i.severity === 'P0' && i.url === '/about/' && i.evidence.missing === 'title')).toBe(true)
|
||
// 双 H1(404.html)
|
||
expect(seo.some((i) => i.severity === 'P0' && i.evidence.page === '404.html' && i.evidence.h1Count === 2)).toBe(true)
|
||
// 缺 description → P2(不阻断——pass 只看 P0)
|
||
expect(seo.some((i) => i.severity === 'P2' && i.evidence.page === 'index.html' && i.evidence.missing === 'description')).toBe(true)
|
||
expect(r.pass).toBe(false) // 但缺 title/双 H1 的 P0 在场 → 阻断
|
||
|
||
// canonical 精确匹配验证:manifest 页指错(/about/ 页 canonical 指到 /)→ P0
|
||
await writeFile(
|
||
join(fx.distDir, 'about', 'index.html'),
|
||
pageHtml({ canonical: `${ORIGIN}/`, links: [] }),
|
||
'utf8',
|
||
)
|
||
await writeFile(join(fx.distDir, '404.html'), pageHtml({ canonical: `${ORIGIN}/404`, links: [] }), 'utf8')
|
||
await writeFile(join(fx.distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
|
||
const r2 = await runFixture(fx)
|
||
const canonicalIssue = r2.issues.find((i) => i.rule === 'seo-baseline' && i.evidence.missing === 'canonical')
|
||
expect(canonicalIssue).toBeDefined()
|
||
expect(canonicalIssue!.severity).toBe('P0')
|
||
expect(canonicalIssue!.url).toBe('/about/')
|
||
expect(canonicalIssue!.evidence.actual).toBe(`${ORIGIN}/`)
|
||
expect(canonicalIssue!.evidence.expected).toBe(`${ORIGIN}/about/`)
|
||
expect(r2.pass).toBe(false)
|
||
|
||
// 纯 P2 面(只缺 description)不阻断:全页恢复、仅 about 缺 description
|
||
await writeFile(
|
||
join(fx.distDir, 'about', 'index.html'),
|
||
pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace(
|
||
'<meta name="description" content="fixture description"/>\n',
|
||
'',
|
||
),
|
||
'utf8',
|
||
)
|
||
const r3 = await runFixture(fx)
|
||
expect(r3.pass).toBe(true)
|
||
expect(r3.issues.every((i) => i.severity === 'P2')).toBe(true)
|
||
expect(r3.issues).toHaveLength(1)
|
||
} finally {
|
||
await rm(fx.work, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('④ case-collision:lowercase 归一后重复 → 冲突对(纯函数直测——NTFS 大小写不敏感无法物化冲突对)', async () => {
|
||
// 过:无重复
|
||
expect(findCaseCollisions(['index.html', 'about/index.html', 'A.png', 'b/c.png'])).toStrictEqual([])
|
||
// fail:仅大小写不同的两个路径 → 冲突对(目录段与文件名段都参与归一)
|
||
expect(findCaseCollisions(['Docs/x.html', 'docs/x.html'])).toStrictEqual([
|
||
{ lower: 'docs/x.html', paths: ['Docs/x.html', 'docs/x.html'] },
|
||
])
|
||
expect(findCaseCollisions(['a/B.html', 'A/b.html', 'A/B.html'])).toStrictEqual([
|
||
{ lower: 'a/b.html', paths: ['A/B.html', 'A/b.html', 'a/B.html'] },
|
||
])
|
||
})
|
||
|
||
it('⑤ artifact-completeness fail:manifest html 行 target_file 缺席 → P0(evidence 带 route 与 target_file)', async () => {
|
||
const fx = await makeGateFixture('sf-gate-comp')
|
||
try {
|
||
// 物理删除 about 产物(模拟主题落盘缺席)
|
||
await rm(join(fx.distDir, 'about'), { recursive: true, force: true })
|
||
const r = await runFixture(fx)
|
||
expect(r.pass).toBe(false)
|
||
const comp = r.issues.filter((i) => i.rule === 'artifact-completeness')
|
||
expect(comp).toHaveLength(1)
|
||
expect(comp[0]!.severity).toBe('P0')
|
||
expect(comp[0]!.url).toBe('/about/')
|
||
expect(comp[0]!.evidence.target_file).toBe('about/index.html')
|
||
// 死链面同时感知缺席(index→about 链接死);SEO 面只查在场文件——缺席文件不产 issue
|
||
expect(rulesOf(r.issues)).toStrictEqual(['internal-links', 'artifact-completeness'])
|
||
} finally {
|
||
await rm(fx.work, { recursive: true, force: true })
|
||
}
|
||
})
|
||
})
|
||
|
||
// ---- mini 全链(DB + 真实 astro 构建——P0 阻断/全过语义) ----
|
||
|
||
interface Work {
|
||
work: string
|
||
storageRoot: string
|
||
allowedRoots: string[]
|
||
}
|
||
|
||
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 })
|
||
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-a-${rand()}`)]))
|
||
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-b-${rand()}`)]))
|
||
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work] }
|
||
}
|
||
|
||
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
|
||
const key = `sfgate-${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 }
|
||
}
|
||
|
||
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs)+ 全链反序 */
|
||
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}'`)
|
||
}
|
||
|
||
describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () => {
|
||
it('任一 P0 → validation_runs FAIL + validation_issues 落库(severity/rule/url/evidence)+ job failed + dist 清场', { timeout: 300_000 }, async () => {
|
||
await withTestDb(async (db) => {
|
||
const w = await makeWork('sf-gate-block')
|
||
try {
|
||
const { key, siteId } = await seedPublishedSite(db, w)
|
||
try {
|
||
const artifactsRoot = join(w.work, 'artifacts')
|
||
let blockedJobId = ''
|
||
const err: unknown = await createBuildJob(db, {
|
||
siteKey: key,
|
||
artifactsRoot,
|
||
idempotencyKey: `build:test:gate-block:${rand()}`,
|
||
onStatus: (s, jobId) => {
|
||
// validating 挂钩点:构建产物已拷出、门禁未跑——向 index.html 注入坏链
|
||
if (s === 'validating') {
|
||
blockedJobId = jobId
|
||
const index = join(artifactsRoot, 'jobs', jobId, 'dist', 'index.html')
|
||
const html = readFileSync(index, 'utf8')
|
||
writeFileSync(index, `${html}<a href="/no-such-page/">injected</a>`, 'utf8')
|
||
}
|
||
},
|
||
}).then(
|
||
() => {
|
||
throw new Error('不应成功')
|
||
},
|
||
(e: unknown) => e,
|
||
)
|
||
expect(err).toBeInstanceOf(BuildError)
|
||
expect((err as BuildError).code).toBe('VALIDATION_FAILED')
|
||
expect((err as BuildError).message).toContain('no-such-page')
|
||
|
||
// job 行:failed 终态 + VALIDATION_FAILED 码
|
||
const job = (await db.execute(`select status, error_code, error_summary from build_jobs
|
||
where id = '${blockedJobId}'`)) as any
|
||
expect(job).toHaveLength(1)
|
||
expect(job[0].status).toBe('failed')
|
||
expect(job[0].error_code).toBe('VALIDATION_FAILED')
|
||
expect(job[0].error_summary).toContain('no-such-page')
|
||
|
||
// validation_runs:FAIL 行(site/job 关联 + rule_version + 起止时间戳)
|
||
const run = (await db.execute(`select vr.status, vr.rule_version, vr.build_job_id,
|
||
extract(epoch from vr.started_at)::float8 as s_ts,
|
||
extract(epoch from vr.finished_at)::float8 as f_ts
|
||
from validation_runs vr where vr.build_job_id = '${blockedJobId}'`)) as any
|
||
expect(run).toHaveLength(1)
|
||
expect(run[0].status).toBe('FAIL')
|
||
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
|
||
expect(run[0].s_ts).toBeGreaterThan(0)
|
||
expect(run[0].f_ts).toBeGreaterThanOrEqual(run[0].s_ts)
|
||
|
||
// validation_issues:每 issue 一行(severity/rule/url/evidence)
|
||
const issues = (await db.execute(`select vi.severity, vi.rule, vi.url, vi.evidence, vi.resolution_status
|
||
from validation_issues vi join validation_runs vr on vi.validation_run_id = vr.id
|
||
where vr.build_job_id = '${blockedJobId}' order by vi.rule, vi.url`)) as any
|
||
expect(issues.length).toBeGreaterThanOrEqual(1)
|
||
const link = issues.find((i: any) => i.rule === 'internal-links')
|
||
expect(link).toBeDefined()
|
||
expect(link.severity).toBe('P0')
|
||
expect(link.url).toBe('/')
|
||
expect(link.evidence.href).toBe('/no-such-page/')
|
||
expect(link.evidence.page).toBe('index.html')
|
||
for (const i of issues) {
|
||
expect(['P0', 'P2']).toContain(i.severity)
|
||
expect(i.resolution_status).toBe('open')
|
||
expect(typeof i.rule).toBe('string')
|
||
}
|
||
|
||
// failed 无产物语义:dist 清场(issue 证据已在库——调试面在 validation_issues)
|
||
expect(existsSync(join(artifactsRoot, 'jobs', blockedJobId, 'dist'))).toBe(false)
|
||
} finally {
|
||
await cleanupSiteRows(db, siteId)
|
||
}
|
||
} finally {
|
||
await rm(w.work, { recursive: true, force: true })
|
||
}
|
||
})
|
||
})
|
||
|
||
it('全过 → validation_runs PASS + 零 issue 行 + job succeeded + 报告 json/html 双落盘(HTML 先写)', { timeout: 300_000 }, async () => {
|
||
await withTestDb(async (db) => {
|
||
const w = await makeWork('sf-gate-pass')
|
||
try {
|
||
const { key, siteId } = await seedPublishedSite(db, w)
|
||
try {
|
||
const statuses: string[] = []
|
||
const result = await createBuildJob(db, {
|
||
siteKey: key,
|
||
artifactsRoot: join(w.work, 'artifacts'),
|
||
onStatus: (s) => statuses.push(s),
|
||
})
|
||
expect(result.status).toBe('succeeded')
|
||
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
|
||
|
||
// validation_runs:PASS 行 + 零 issue
|
||
const run = (await db.execute(`select status, rule_version from validation_runs
|
||
where build_job_id = '${result.jobId}'`)) as any
|
||
expect(run).toHaveLength(1)
|
||
expect(run[0].status).toBe('PASS')
|
||
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
|
||
const issueCount = (await db.execute(`select count(*)::int as n from validation_issues vi
|
||
join validation_runs vr on vi.validation_run_id = vr.id
|
||
where vr.build_job_id = '${result.jobId}'`)) as any
|
||
expect(Number(issueCount[0].n)).toBe(0)
|
||
|
||
// 报告双落盘:build.json 在则 build.html 必在(HTML 先写窄窗口修);
|
||
// html 为转义模板渲染(含 jobId 与 PASS 面、无 script)
|
||
const reportJson = JSON.parse(await readFile(result.reportPath, 'utf8'))
|
||
expect(reportJson.validation.status).toBe('PASS')
|
||
expect(reportJson.validation.ruleVersion).toBe(GATE_RULE_VERSION)
|
||
const reportHtmlPath = join(w.work, 'artifacts', 'jobs', result.jobId, 'reports', 'build.html')
|
||
expect(statSync(reportHtmlPath).isFile()).toBe(true)
|
||
const html = await readFile(reportHtmlPath, 'utf8')
|
||
expect(html).toContain(result.jobId)
|
||
expect(html).toContain('PASS')
|
||
expect(html).not.toContain('<script')
|
||
} finally {
|
||
await cleanupSiteRows(db, siteId)
|
||
}
|
||
} finally {
|
||
await rm(w.work, { recursive: true, force: true })
|
||
}
|
||
})
|
||
})
|
||
})
|