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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user