Files
order_site/apps/backend/src/db-backup.test.ts
T
yml2213 8ecb8a879a fix(backup): 修复本地备份保留修剪静默失效,本地默认保留降到 2 份
bug:prune_local 里 ls 的 cd 只发生在命令替换的子 shell 中,
rm 却拿裸文件名在 PROJECT_ROOT 下执行——目标文件不存在,
而 rm -f 对不存在的文件静默成功。结果是"清理成功"日志照打、
本地备份数量却一直超出 KEEP(生产 configs 目录累积到 43 份,
dump 10 份,均超出 KEEP=7)。

修复:rm 改用 ${dir}/${file} 绝对路径,并补源码级守卫测试锁定。

顺带把 .env.server.example 的默认保留从 7 降到 2:
本地备份只是断网应急,OSS 已有完整备份;而部署脚本的磁盘预检
要求 可用 ≥ 备份目录×2,本地留太多会直接把部署卡死
(这次就是 3.8G 备份触发预检失败 → 被迫清构建缓存 → 全量构建变慢)。
2026-10-03 15:51:01 +08:00

221 lines
6.7 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { Readable } from 'node:stream'
import {
manifestKeyFor,
parseBackupArgs,
pickLatestDumpKey,
resolveBackupStorageConfig,
runDownload,
runLatest,
runList,
runUpload,
} from './db-backup.js'
import type { StorageObjectSummary } from './services/file-storage/object-storage.js'
test('resolveBackupStorageConfig 优先 DB_BACKUP_OSS_* 并按字段回退 STORAGE_OSS_*', () => {
const config = resolveBackupStorageConfig({
DB_BACKUP_OSS_ENDPOINT: 'https://backup.example.com',
STORAGE_OSS_ENDPOINT: 'https://storage.example.com',
STORAGE_OSS_BUCKET: 'order-site',
STORAGE_OSS_ACCESS_KEY_ID: 'storage-key',
DB_BACKUP_OSS_ACCESS_KEY_ID: 'backup-key',
STORAGE_OSS_SECRET_ACCESS_KEY: 'storage-secret',
})
assert.equal(config.endpoint, 'https://backup.example.com')
assert.equal(config.bucket, 'order-site')
assert.equal(config.accessKeyId, 'backup-key')
assert.equal(config.secretAccessKey, 'storage-secret')
assert.equal(config.region, 'oss-cn-hangzhou')
})
test('resolveBackupStorageConfig 全部未配置时返回空值由调用方校验', () => {
const config = resolveBackupStorageConfig({})
assert.equal(config.endpoint, '')
assert.equal(config.bucket, '')
})
test('parseBackupArgs 解析命令与参数并去掉前缀尾部斜杠', () => {
const args = parseBackupArgs([
'upload',
'--key',
'backups/postgres/order_site-20260824-030000.dump',
'--prefix',
'backups/postgres/',
'--kind',
'app-configs',
'--size',
'1234',
])
assert.equal(args.command, 'upload')
assert.equal(args.key, 'backups/postgres/order_site-20260824-030000.dump')
assert.equal(args.prefix, 'backups/postgres')
assert.equal(args.kind, 'app-configs')
assert.equal(args.size, 1234)
})
test('parseBackupArgs 无参数时使用默认前缀与类型', () => {
const args = parseBackupArgs(['list'])
assert.equal(args.command, 'list')
assert.equal(args.prefix, 'backups/postgres')
assert.equal(args.kind, 'postgres-dump')
assert.equal(args.key, '')
assert.equal(args.allowUnverified, false)
})
test('parseBackupArgs 支持显式兼容未校验备份', () => {
assert.equal(parseBackupArgs(['latest', '--allow-unverified']).allowUnverified, true)
})
test('manifestKeyFor 在备份 key 后追加清单后缀', () => {
assert.equal(manifestKeyFor('backups/postgres/a.dump'), 'backups/postgres/a.dump.manifest.json')
})
test('pickLatestDumpKey 只认 dump 并取最近修改的对象', () => {
const key = pickLatestDumpKey([
{
key: 'backups/postgres/a.dump',
size: 1,
etag: '',
lastModified: new Date('2026-08-01T00:00:00Z'),
},
{
key: 'backups/postgres/a.dump.manifest.json',
size: 1,
etag: '',
lastModified: new Date('2026-08-23T00:00:00Z'),
},
{
key: 'backups/configs/a.tar.gz',
size: 1,
etag: '',
lastModified: new Date('2026-08-23T00:00:00Z'),
},
{
key: 'backups/postgres/b.dump',
size: 1,
etag: '',
lastModified: new Date('2026-08-22T00:00:00Z'),
},
])
assert.equal(key, 'backups/postgres/b.dump')
})
test('pickLatestDumpKey 无备份时返回 null,无时间时按 key 兜底', () => {
assert.equal(pickLatestDumpKey([]), null)
assert.equal(
pickLatestDumpKey([
{ key: 'backups/postgres/order_site-1.dump', size: 1, etag: '' },
{ key: 'backups/postgres/order_site-2.dump', size: 1, etag: '' },
]),
'backups/postgres/order_site-2.dump',
)
})
test('runUpload 支持流式输入并生成带长度和 sha256 的 manifest', async () => {
const uploads: Array<{ key: string; content: Buffer; contentLength?: number }> = []
const storage = {
putObject: async (input: {
key: string
content: Buffer | Readable
contentLength?: number
}) => {
const chunks: Buffer[] = []
if (Buffer.isBuffer(input.content)) {
chunks.push(input.content)
} else {
for await (const chunk of input.content) chunks.push(Buffer.from(chunk))
}
uploads.push({
key: input.key,
content: Buffer.concat(chunks),
contentLength: input.contentLength,
})
},
} as never
await runUpload(
storage,
parseBackupArgs([
'upload',
'--key',
'backups/configs/a.tar.gz',
'--kind',
'app-configs',
'--size',
'5',
]),
Readable.from([Buffer.from('hello')]),
)
assert.equal(uploads.length, 2)
assert.equal(uploads[0]?.content.toString(), 'hello')
assert.equal(uploads[0]?.contentLength, 5)
assert.match(uploads[1]?.content.toString() || '', /"size": 5/)
assert.match(uploads[1]?.content.toString() || '', /sha256/)
})
test('runLatest 跳过没有 manifest 的新对象并选择最近的有效备份', async () => {
const objects: StorageObjectSummary[] = [
{
key: 'backups/postgres/new.dump',
size: 5,
etag: '',
lastModified: new Date('2026-08-23T03:00:00Z'),
},
{
key: 'backups/postgres/valid.dump',
size: 5,
etag: '',
lastModified: new Date('2026-08-22T03:00:00Z'),
},
]
const storage = {
listObjects: async () => objects,
getObject: async (key: string) => {
if (key.endsWith('new.dump.manifest.json')) {
const error = Object.assign(new Error('missing'), { name: 'NoSuchKey' })
throw error
}
return {
reader: Readable.from([
JSON.stringify({
key: 'backups/postgres/valid.dump',
kind: 'postgres-dump',
size: 5,
sha256: 'a'.repeat(64),
createdAt: '2026-08-22T03:00:00Z',
}),
]),
}
},
} as never
assert.equal(
await runLatest(storage, parseBackupArgs(['latest', '--prefix', 'backups/postgres'])),
'backups/postgres/valid.dump',
)
})
test('backup-db.sh 的本地保留修剪必须用绝对路径删除(曾静默失效导致备份无限累积)', async () => {
const { readFile } = await import('node:fs/promises')
const script = await readFile(new URL('../../../deploy/backup-db.sh', import.meta.url), 'utf8')
const pruneBody = script.slice(script.indexOf('prune_local() {'), script.indexOf('main() {'))
// 曾经的 bug:cd 只发生在 ls 的命令替换子 shell 里,rm 却拿裸文件名在
// PROJECT_ROOT 下删(目标不存在),rm -f 静默吞错 → 本地备份远超 KEEP 份数。
assert.ok(
pruneBody.includes('rm -f -- "${dir}/${file}"'),
'修剪必须删除 ${dir}/${file};只删文件名会在项目根目录下落空',
)
assert.ok(
!/"\$\{file\}"\s*$/m.test(pruneBody.replace('rm -f -- "${dir}/${file}"', '')),
'不得再出现只按文件名删除的写法',
)
})