fix(source): DB 事务化;storageRoot 入 metadata;siteCreated 与并发测试;CLI 体验与校验小修

评审修复(4 项 Important + 8 项 Minor):
- DB 事务化:site→connection→snapshots 全部落库包进 db.transaction 单事务
  ("任何失败零落库"字面成立);助手改收 Pick<select|insert|execute> 结构类型
  (tx 与直连 db 通用)。并发撞唯一约束不在事务内补查(语句失败后续查 25P02),
  改为 registerSource 层事务级重试(≤3 次,第二次 select 命中复用分支)
- connection 并发:select-then-insert 无约束可兜底,事务内 pg_advisory_xact_lock
  (siteId:name) 串行化同键登记(kind 枚举扩展推迟 REST 通道,注释注明)
- storageRoot 绝对路径固化进每条 snapshot metadata 与报告 inputs(快照不可变,
  事后无法补写——永久缺口封堵)
- ensureSite 返回 created;RegisterResult.siteCreated;CLI 站点已存在时提示
  --name/--locale/--origin 未生效
- 媒体复制期逐文件 sha 复核(copyAndHash 对拍清单,源中途变更 → COPY_FAILED)
- 空媒体目录拒绝(EMPTY_MEDIA_DIR)
- CLI:PATH_NOT_ALLOWED 追加 --allow 提示;--origin 经 new URL 校验(http/s)、
  --locale 语言标签形状校验,fail fast;开跑打印生效白名单(审计)
- makeRunId 服务导出、CLI 复用;.tmp 残留手工清理策略写入文件头注释
- 测试 +2:并发同输入恰好一组行/双方同 id/新建标志恰一(3 次重跑稳定);
  isUniqueViolation 形状单测(drizzle cause.code / 裸 code / 其他)

全套 263 绿;typecheck / lint 绿;CLI 冒烟对临时库验证事务路径、siteCreated
输出、defaults 落库(en-US / https://qa.example)、白名单打印与校验报错

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 13:06:48 +08:00
parent 9732e1b684
commit c28c1655a4
4 changed files with 255 additions and 126 deletions
+74 -3
View File
@@ -10,7 +10,9 @@
// 副本内容与原输入一致(后续任务只读副本);
// 5. 路径安全:白名单外 / 越界遍历 / **中间段链接逃逸(整路径 realpath 后判定)** /
// ENOENT / 类型不符均清晰报错且零落库;
// 6. 报告落 artifacts/register/<runId>/reports/register.json(runId=时间戳+随机)。
// 6. 报告落 artifacts/register/<runId>/reports/register.json(runId=时间戳+随机);
// 7. 并发登记同输入恰好一组行(DB 单事务 + 唯一约束/advisory lock 收敛)、双方同 id;
// 8. 空媒体目录拒绝(EMPTY_MEDIA_DIR)、metadata 固化 storageRoot、siteCreated 语义。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。
// 注:符号链接跳过用目录 junction 验证(Windows 免特权;非 Windows 退化为普通符号链接);
// 创建失败(极端沙箱)时该断言静默放行——junction 创建不是本测试的被测契约。
@@ -22,6 +24,7 @@ import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
isUniqueViolation,
registerSource,
SourceRegisterError,
type RegisterInput,
@@ -204,6 +207,8 @@ describe('source:register 服务', () => {
const r2 = await registerSource(db, makeInput(ctx, key))
expect(r2.siteId).toBe(r1.siteId)
expect(r2.siteCreated).toBe(false)
expect(r1.siteCreated).toBe(true)
expect(r2.connectionId).toBe(r1.connectionId)
expect(r2.connectionCreated).toBe(false)
expect(r2.snapshots).toHaveLength(3)
@@ -248,6 +253,8 @@ describe('source:register 服务', () => {
}
// 副本路径 ≠ 源路径(后续任务只读副本)
expect(dest).not.toBe(s.sourcePath)
// storageRoot 固化进 metadata(快照不可变,事后无法补写)
expect((s.metadata as { storageRoot: string }).storageRoot).toBe(resolve(ctx.storageRoot))
}
} finally {
await cleanupSiteRows(db, r.siteId)
@@ -302,10 +309,16 @@ describe('source:register 服务', () => {
input: { ...base },
code: 'NO_INPUTS',
},
{
label: '空媒体目录',
input: { ...base, mediaDir: join(ctx.work, 'empty-media') },
code: 'EMPTY_MEDIA_DIR',
},
]
// 越界遍历用例的配套文件:work 根下放 escape.txt,incoming/ 只是白名单根
// 用例配套布局:work 根放 escape.txt(越界目标)、incoming/(收窄白名单根)、empty-media/(空目录)
await writeFile(join(ctx.work, 'escape.txt'), 'escape')
await mkdir(join(ctx.work, 'incoming'), { recursive: true })
await mkdir(join(ctx.work, 'empty-media'), { recursive: true })
for (const c of cases) {
const err: SourceRegisterError = await registerSource(db, c.input).then(
@@ -403,17 +416,20 @@ describe('source:register 服务', () => {
runId: string
siteKey: string
siteId: string
inputs: { kind: string; sourcePath: string; sha256: string; byteSize: number; durationMs: number }[]
storageRoot: string
inputs: { kind: string; sourcePath: string; sha256: string; byteSize: number; storageRoot: string; durationMs: number }[]
totals: { snapshots: number; reused: number; byteSize: number }
durationMs: number
}
expect(report.runId).toBe(r.runId)
expect(report.siteKey).toBe(key)
expect(report.siteId).toBe(r.siteId)
expect(report.storageRoot).toBe(resolve(ctx.storageRoot))
expect(report.inputs).toHaveLength(3)
const wxrInput = report.inputs.find((i) => i.kind === 'wxr')!
expect(wxrInput.sha256).toBe(sha256(await readFile(WXR_FIXTURE)))
expect(wxrInput.byteSize).toBe((await readFile(WXR_FIXTURE)).length)
expect(wxrInput.storageRoot).toBe(resolve(ctx.storageRoot))
expect(wxrInput.durationMs).toBeGreaterThanOrEqual(0)
expect(report.totals.snapshots).toBe(3)
expect(report.totals.reused).toBe(0)
@@ -426,4 +442,59 @@ describe('source:register 服务', () => {
})
})
})
it('并发 register 同输入:恰好一组行,双方返回同一组 id(事务 + 唯一约束/advisory lock 收敛)', async () => {
await withTestDb(async (db) => {
await withCtx(async (ctx) => {
const key = `sfreg-${rand()}`
const input: RegisterInput = {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: ctx.allowedRoots,
storageRoot: ctx.storageRoot,
reportRoot: ctx.reportRoot,
}
const [r1, r2] = await Promise.all([registerSource(db, input), registerSource(db, input)])
try {
// 并发终态契约:同 id、同快照;"谁新建"分支按竞态分配,但恰好各一方
expect(r1.siteId).toBe(r2.siteId)
expect(r1.connectionId).toBe(r2.connectionId)
expect(r1.snapshots.map((s: RegisteredSnapshot) => s.id)).toEqual(
r2.snapshots.map((s: RegisteredSnapshot) => s.id),
)
expect(Number(r1.siteCreated) + Number(r2.siteCreated)).toBe(1)
expect(Number(r1.connectionCreated) + Number(r2.connectionCreated)).toBe(1)
expect(
r1.snapshots.map((s: RegisteredSnapshot) => !s.reused).filter(Boolean).length +
r2.snapshots.map((s: RegisteredSnapshot) => !s.reused).filter(Boolean).length,
).toBe(1)
const counts = (await db.execute(
`select (select count(*) from source_snapshots where site_id = '${r1.siteId}') as snaps,
(select count(*) from source_connections where site_id = '${r1.siteId}') as conns,
(select count(*) from sites where key = '${key}') as sites`,
)) as any
expect(counts[0]).toEqual({ snaps: '1', conns: '1', sites: '1' })
} finally {
await cleanupSiteRows(db, r1.siteId)
}
})
})
})
it('isUniqueViolation 形状识别:drizzle 包裹(cause.code)/裸 postgres code/其他均正确', () => {
// drizzle 会把 postgres 错误包成 DrizzleQueryError:message 只剩 "Failed query",真 code 在 cause
const drizzleWrapped = Object.assign(new Error('Failed query: insert into sites ...'), {
cause: Object.assign(new Error('duplicate key value violates unique constraint "sites_key_unique"'), {
code: '23505',
}),
})
expect(isUniqueViolation(drizzleWrapped)).toBe(true)
// 裸 postgres 错误(code 直挂)
expect(isUniqueViolation(Object.assign(new Error('dup'), { code: '23505' }))).toBe(true)
// 其他错误码 / 无 code → false
expect(isUniqueViolation(Object.assign(new Error('x'), { cause: { code: '23508' } }))).toBe(false)
expect(isUniqueViolation(Object.assign(new Error('x'), { code: '42P01' }))).toBe(false)
expect(isUniqueViolation(new Error('plain'))).toBe(false)
expect(isUniqueViolation(null)).toBe(false)
})
})