fix(backups): сохранять запись при неполной очистке файлов

This commit is contained in:
Denozordec committed 2026-10-01 16:14:00 +07:00
1 parent 4c5604bf87
commit af86d2c3cd
10 files changed
+88 -19

No files matched your search

@@ -0,0 +1,30 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import path from "node:path"
import { backupDeletionPath, deleteStoredBackup } from "./backup-deletion.js"
const directory = path.resolve("storage", "backups"), entry = { id: "backup-1", filename: "config.rsc", s3Key: "object" }
test("storage acknowledgements precede catalog deletion, local-only skips S3", async () => {
for (const remote of [true, false]) {
const calls: string[] = []
await deleteStoredBackup({ ...entry, s3Key: remote ? entry.s3Key : null }, directory, { removeS3: async key => { assert.equal(key, "object"); calls.push("s3") }, removeLocal: async file => { assert.equal(file, path.join(directory, entry.filename)); calls.push("local") }, removeRecord: async id => { assert.equal(id, entry.id); calls.push("db") } })
assert.deepEqual(calls, remote ? ["s3", "local", "db"] : ["local", "db"])
}
})
test("remote, local and DB errors propagate; partial cleanup can be retried", async () => {
for (const failing of ["s3", "local", "db"]) {
const calls: string[] = []; let fail = true
const step = async (name: string) => { calls.push(name); if (fail && name === failing) throw new Error(name + " unavailable") }
const deps = { removeS3: () => step("s3"), removeLocal: () => step("local"), removeRecord: () => step("db") }
await assert.rejects(deleteStoredBackup(entry, directory, deps), new RegExp(failing + " unavailable"))
assert.deepEqual(calls, ["s3", "local", "db"].slice(0, ["s3", "local", "db"].indexOf(failing) + 1))
fail = false; calls.length = 0; await deleteStoredBackup(entry, directory, deps); assert.deepEqual(calls, ["s3", "local", "db"])
}
})
test("unsafe filename rejects before any storage mutation", async () => {
for (const filename of ["", ".", "..", "../config.rsc", "..\\config.rsc", "/config.rsc", "C:\\config.rsc", "nested/config.rsc"]) {
assert.throws(() => backupDeletionPath(directory, filename)); let mutated = false
const mutate = async () => { mutated = true }
await assert.rejects(deleteStoredBackup({ ...entry, filename }, directory, { removeS3: mutate, removeLocal: mutate, removeRecord: mutate })); assert.equal(mutated, false)
}
})
+20
View File
@@ -0,0 +1,20 @@
import path from "node:path"
export function backupDeletionPath(directory: string, filename: string): string {
if (!filename || filename === "." || filename === ".." || /[\\/]/.test(filename) || path.isAbsolute(filename)) throw new Error("Некорректное имя файла резервной копии")
const target = path.resolve(directory, filename), relative = path.relative(path.resolve(directory), target)
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Файл резервной копии вне хранилища")
return target
}
/** Keep the catalog entry until every configured storage cleanup has acknowledged success. */
export async function deleteStoredBackup(entry: { id: string; filename: string; s3Key?: string | null }, directory: string, deps: {
removeS3: (key: string) => Promise<void>
removeLocal: (file: string) => Promise<void>
removeRecord: (id: string) => Promise<void>
}): Promise<void> {
const file = backupDeletionPath(directory, entry.filename)
if (entry.s3Key) await deps.removeS3(entry.s3Key)
await deps.removeLocal(file)
await deps.removeRecord(entry.id)
}
+9 -12
View File
@@ -8,6 +8,7 @@ import type {
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
import { mapBackupEntry } from "./backup-dto.js"
import { deleteStoredBackup } from "./backup-deletion.js"
import { planBackupStorageUpdate } from "./backup-storage-update.js"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
@@ -88,19 +89,15 @@ export async function deleteBackupRecord(id: string): Promise<BackupMeta | null>
const row = await getBackupRow(id)
if (!row) return null
const meta = rowToMeta(row)
if (row.s3Key) {
try {
await deleteStoredBackup(row, BACKUPS_DIR, {
removeS3: async key => {
const cfg = await getS3ConfigIfEnabled()
if (cfg) {
const client = createS3ClientFromConfig(cfg)
await s3DeleteObject(client, cfg.bucket, row.s3Key)
}
} catch {
/* объект мог уже отсутствовать */
}
}
await db.delete(backupEntries).where(eq(backupEntries.id, id))
await rm(path.join(BACKUPS_DIR, row.filename), { force: true })
if (!cfg) throw new Error("Очистка S3 не подтверждена: восстановите подключение к хранилищу резервной копии")
await s3DeleteObject(createS3ClientFromConfig(cfg), cfg.bucket, key)
},
removeLocal: file => rm(file, { force: true }),
removeRecord: async backupId => { await db.delete(backupEntries).where(eq(backupEntries.id, backupId)) },
})
return meta
}