diff --git a/backend/package.json b/backend/package.json index 63dbe5a5..041c0e96 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,7 @@ "test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts", - "test:backups": "tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", + "test:backups": "tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts", diff --git a/backend/src/routes/backups-operations.test.ts b/backend/src/routes/backups-operations.test.ts index 3a2b912d..a6bc462a 100644 --- a/backend/src/routes/backups-operations.test.ts +++ b/backend/src/routes/backups-operations.test.ts @@ -5,8 +5,9 @@ import { backupJobResponseSchema } from "@mmapp/contracts/backups" import { createBackupsRoutes } from "./backups.js" import { permissionForRequest } from "../lib/permissions.js" const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) +let deleteOutcome = "success", deleted = 0 let created = 0, restored = 0, eventCalls = 0, restoreFail = false -await app.register(createBackupsRoutes({ listServers: async () => [{ id: 1 }, { id: 2 }], create: async id => { created++; if (id === "2") throw new Error("RouterOS unavailable"); return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } }, restore: async () => { restored++; if (restoreFail) throw new Error("import failed"); return { filename: "config.rsc", serverName: "router" } }, appendEvent: async () => { eventCalls++; throw new Error("event DB unavailable") } }), { prefix: "/api" }) +await app.register(createBackupsRoutes({ delete: async () => { deleted++; if (deleteOutcome === "error") throw new Error("S3 unavailable"); if (deleteOutcome === "missing") return null; return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "both" } }, listServers: async () => [{ id: 1 }, { id: 2 }], create: async id => { created++; if (id === "2") throw new Error("RouterOS unavailable"); return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } }, restore: async () => { restored++; if (restoreFail) throw new Error("import failed"); return { filename: "config.rsc", serverName: "router" } }, appendEvent: async () => { eventCalls++; throw new Error("event DB unavailable") } }), { prefix: "/api" }) const post = (payload: unknown) => app.inject({ method: "POST", url: "/api/backups/create", payload: payload as Record }) for (const serverIds of [[], ["1", "missing"], ["1", "3"], ["1", "1"], ["0"], ["1junk"], [9007199254740992]]) assert.equal((await post({ serverIds })).statusCode, 400) assert.equal(created, 0) @@ -29,4 +30,8 @@ assert.equal((await post({ requestId, serverIds: ["1"], notes: "other" })).statu const success = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(success.statusCode, 200); assert.deepEqual(success.json(), { filename: "config.rsc", serverName: "router" }); assert.equal(restored, 1) restoreFail = true; const failure = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(failure.statusCode, 400); assert.equal(failure.json().error, "import failed") assert.equal(permissionForRequest("POST", "/api/backups/create"), "mm:backups:write"); assert.equal(permissionForRequest("POST", "/api/backups/backup-1/restore"), "mm:backups:write"); assert.equal(permissionForRequest("GET", "/api/backups/jobs/job"), "mm:backups:read") +assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/backup-1" })).statusCode, 204) +deleteOutcome = "error"; assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/backup-1" })).statusCode, 500) +deleteOutcome = "missing"; assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/missing" })).statusCode, 404); assert.equal(deleted, 3) +assert.equal(permissionForRequest("DELETE", "/api/backups/backup-1"), "mm:backups:write") await app.close(); console.log("Backups HTTP: event failure does not relabel created/restore, invalid targets have no side effects, partial results and RBAC") diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index 1411d70c..4183e9f8 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -36,7 +36,7 @@ const BackupJobIdParamSchema = z.object({ jobId: z.string().min(1), }) -export function createBackupsRoutes(deps: { listServers?: () => Promise>; create?: typeof runBackupForServer; restore?: typeof restoreBackupToDevice; appendEvent?: typeof appendEvent } = {}): FastifyPluginAsyncZod { +export function createBackupsRoutes(deps: { listServers?: () => Promise>; create?: typeof runBackupForServer; restore?: typeof restoreBackupToDevice; delete?: typeof deleteBackupRecord; appendEvent?: typeof appendEvent } = {}): FastifyPluginAsyncZod { return async (app) => { const backupJobs = new Map() const event = (input: Parameters[0]) => recordBackupEvent(input, deps.appendEvent ?? appendEvent, error => app.log.warn({ err: error }, "Не удалось записать событие резервных копий")) @@ -207,7 +207,7 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise { - const hit = await deleteBackupRecord(req.params.id) + const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id) if (!hit) return reply.status(404).send({ error: "Бэкап не найден" }) return reply.status(204).send() }) diff --git a/backend/src/services/backup-deletion.test.ts b/backend/src/services/backup-deletion.test.ts new file mode 100644 index 00000000..3b4ccc62 --- /dev/null +++ b/backend/src/services/backup-deletion.test.ts @@ -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) + } +}) diff --git a/backend/src/services/backup-deletion.ts b/backend/src/services/backup-deletion.ts new file mode 100644 index 00000000..75863b52 --- /dev/null +++ b/backend/src/services/backup-deletion.ts @@ -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 + removeLocal: (file: string) => Promise + removeRecord: (id: string) => Promise +}): Promise { + const file = backupDeletionPath(directory, entry.filename) + if (entry.s3Key) await deps.removeS3(entry.s3Key) + await deps.removeLocal(file) + await deps.removeRecord(entry.id) +} diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index a2a9e4b9..34bfdc69 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -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 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 } diff --git a/components/backups/backup-confirm-dialogs.tsx b/components/backups/backup-confirm-dialogs.tsx index 92efc451..aced8424 100644 --- a/components/backups/backup-confirm-dialogs.tsx +++ b/components/backups/backup-confirm-dialogs.tsx @@ -38,7 +38,7 @@ export function BackupDeleteDialog({ Удалить бэкап? Файл {backup?.filename} будет удалён - {backup?.storage === "s3" || backup?.storage === "both" ? " локально и из S3" : " с диска"}. + {backup?.storage === "s3" || backup?.storage === "both" ? " локально; объект S3 — через текущее подключение к хранилищу" : " с диска"}. {error &&

{error}

} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 02c9ef47..78704ba2 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11А.29.2 — backend cleanup удаления резервных копий, затем restore identity/неизвестный результат. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.29.3 — restore identity/неизвестный результат. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index a1596827..29e807cf 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: операции frontend, далее 11А.29.2 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: подтверждённая очистка, далее 11А.29.3 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11А.29.2 — подтверждённая очистка перед удалением записи (2026-10-01) + +- Backend удаляет S3 объект и локальный файл до записи каталога. Ошибка S3/диска/DB выходит наружу, не превращается в 204. Отсутствие рабочего S3 подключения при s3Key отклоняет удаление. Сохранившаяся запись позволяет повторить частичную очистку; local rm force и S3 DeleteObject допускают уже отсутствующий объект. +- Имя локального файла проверяется до любых side effects: разделители/traversal/absolute target отклоняются, путь остаётся внутри storage/backups. Проверки не удаляют реальные файлы. +- Диалог явно указывает текущее подключение S3. Записи исторически хранят только object key, исходные bucket/endpoint не сохранялись: отсутствие объекта в прежнем bucket, очистка версий S3 и внешние конкурентные изменения не подтверждены. Это ограничение не скрыто обещанием физического удаления всех исторических копий. +- 3 fault/order/path unit, полный backend test:backups (DTO/storage/job/HTTP/S3), HTTP DELETE 204/500/404 и RBAC, backend production build и профильный ESLint прошли. Frontend изменён только текстом пояснения. Реальные PostgreSQL/RouterOS/S3 не проверены. +- **Следующий блок 11А.29.3:** restore receipt backupId/serverId, неизвестный результат импорта и повтор; затем URL/Frame/адаптивность, scheduler cleanup и локальная приёмка резервных копий. + ## Блок 11А.29.1 — подтверждение операций и отмена старого контекста (2026-10-01) - Delete/restore используют синхронный общий lock и immutable цель. Ошибка API сохраняет диалог/файл и показывается внутри него; Escape/отмена заблокированы во время запроса. Только DELETE 204 удаляет подтверждённую строку и закрывает диалог. Ошибка отдельного refresh не отменяет подтверждение удаления. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 1e7cb758..5482c399 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -373,3 +373,12 @@ - 9 API unit, 6 operations E2E (повтор после исправления HTML), все 28 context E2E, TypeScript, профильный ESLint и production frontend прошли. Реальные RouterOS/PostgreSQL/S3 не проверены. - **Следующий блок 11А.29.2:** backend cleanup удаления: не игнорировать S3 ошибки, удалять запись после файлов, сохранить возможность повторить частичную очистку. Исходные bucket/endpoint в записи не хранятся — историческое происхождение S3 остаётся ограничением. Затем restore identity/неизвестный результат, URL/Frame/адаптивность и scheduler/приёмка. + +## Блок 11А.29.2 — подтверждённая очистка перед удалением записи (2026-10-01) + +- Backend удаляет S3 объект и локальный файл до записи каталога. Ошибка S3/диска/DB выходит наружу, не превращается в 204. Отсутствие рабочего S3 подключения при s3Key отклоняет удаление. Сохранившаяся запись позволяет повторить частичную очистку; local rm force и S3 DeleteObject допускают уже отсутствующий объект. +- Имя локального файла проверяется до любых side effects: разделители/traversal/absolute target отклоняются, путь остаётся внутри storage/backups. Проверки не удаляют реальные файлы. +- Диалог явно указывает текущее подключение S3. Записи исторически хранят только object key, исходные bucket/endpoint не сохранялись: отсутствие объекта в прежнем bucket, очистка версий S3 и внешние конкурентные изменения не подтверждены. Это ограничение не скрыто обещанием физического удаления всех исторических копий. +- 3 fault/order/path unit, полный backend test:backups (DTO/storage/job/HTTP/S3), HTTP DELETE 204/500/404 и RBAC, backend production build и профильный ESLint прошли. Frontend изменён только текстом пояснения. Реальные PostgreSQL/RouterOS/S3 не проверены. +- **Следующий блок 11А.29.3:** restore receipt backupId/serverId, неизвестный результат импорта и повтор; затем URL/Frame/адаптивность, scheduler cleanup и локальная приёмка резервных копий. +